From a0d7342752fff9c15f70c1816576e1d15c8ea4ac Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 20 Dec 2022 11:58:30 -0800 Subject: [PATCH 1/2] Implement missing strftime formats #541 --- Fluid.Tests/MiscFiltersTests.cs | 43 +++++-- Fluid/Filters/MiscFilters.cs | 202 +++++++++++++++++++++----------- 2 files changed, 166 insertions(+), 79 deletions(-) diff --git a/Fluid.Tests/MiscFiltersTests.cs b/Fluid.Tests/MiscFiltersTests.cs index 8430dfbc..6fb9c94a 100644 --- a/Fluid.Tests/MiscFiltersTests.cs +++ b/Fluid.Tests/MiscFiltersTests.cs @@ -203,6 +203,7 @@ public async Task EscapeOnce() [Theory] [InlineData("%a", "Tue")] + [InlineData("%a", "Sun", "2022-06-26 00:00:00 -0500")] [InlineData("%^a", "TUE")] [InlineData("%A", "Tuesday")] [InlineData("%^A", "TUESDAY")] @@ -213,12 +214,17 @@ public async Task EscapeOnce() [InlineData("%c", "Tuesday, August 1, 2017 5:04:36 PM")] [InlineData("%^c", "TUESDAY, AUGUST 1, 2017 5:04:36 PM")] [InlineData("%C", "20")] + [InlineData("%C", "02", "0217-01-01")] [InlineData("%d", "01")] [InlineData("%_d", " 1")] [InlineData("%-d", "1")] [InlineData("%D", "08/01/17")] [InlineData("%e", " 1")] [InlineData("%F", "2017-08-01")] + [InlineData("%G", "2022", "2023-01-01 12:00:00")] + [InlineData("%G", "2024", "2024-01-01 12:00:00")] + [InlineData("%g", "22", "2023-01-01 12:00:00")] + [InlineData("%g", "24", "2024-01-01 12:00:00")] [InlineData("%H", "17")] [InlineData("%I", "05")] [InlineData("%j", "213")] @@ -229,21 +235,38 @@ public async Task EscapeOnce() [InlineData("%_m", " 8")] [InlineData("%-m", "8")] [InlineData("%M", "04")] + [InlineData("%n", "\n")] + [InlineData("%N", "123000000")] + [InlineData("%3N", "123")] + [InlineData("%1N", "1")] [InlineData("%p", "PM")] [InlineData("%P", "pm")] [InlineData("%r", "05:04:36 PM")] [InlineData("%R", "17:04")] [InlineData("%s", "1501578276")] + [InlineData("%20s", "00000000001501578276")] + [InlineData("%_20s", " 1501578276")] [InlineData("%S", "36")] + [InlineData("%t", "\t")] [InlineData("%T", "17:04:36")] [InlineData("%u", "2")] - [InlineData("%U", "31")] + [InlineData("%u", "7", "2022-06-26 00:00:00")] + [InlineData("%U", "52", "2016-12-31T12:00:00")] // Saturday 12/31 + [InlineData("%U", "01", "2017-01-01T12:00:00")] // Sunday 01/01 - week begins on a Sunday (for %U) + [InlineData("%U", "01", "2017-01-02T12:00:00")] // Monday 01/02 - week begins on a Sunday (for %U) + [InlineData("%U", "26", "2022-06-26T00:00:00")] [InlineData("%v", " 1-Aug-2017")] [InlineData("%^v", " 1-AUG-2017")] [InlineData("%V", "31")] - [InlineData("%W", "32")] + [InlineData("%W", "52", "2016-12-31T12:00:00")] // Saturday 12/31 + [InlineData("%W", "00", "2017-01-01T12:00:00")] // Sunday 01/01 - still not first week of the year + [InlineData("%W", "01", "2017-01-02T12:00:00")] // Monday 01/02 - week begins on a Monday (for %W) + [InlineData("%W", "25", "2022-06-26T00:00:00")] [InlineData("%y", "17")] [InlineData("%Y", "2017")] + [InlineData("%Y", "0217", "0217-01-01")] + [InlineData("%y", "17", "0217-01-01")] + [InlineData("%y", "07", "0207-01-01")] [InlineData("%z", "+0800")] [InlineData("%Z", "+08:00")] [InlineData("%:z", "+08:00")] @@ -252,15 +275,15 @@ public async Task EscapeOnce() [InlineData("It is %r", "It is 05:04:36 PM")] [InlineData("Chained %z%:z%a%a%^a", "Chained +0800+08:00TueTueTUE")] [InlineData("%Y-%m-%dT%H:%M:%S.%L", "2017-08-01T17:04:36.123")] - public async Task Date(string format, string expected) + public async Task Date(string format, string expected, string dateTime = "2017-08-01T17:04:36.123+08:00") { - var input = new DateTimeValue(new DateTimeOffset( - new DateTime(2017, 8, 1, 17, 4, 36, 123), TimeSpan.FromHours(8))); - var arguments = new FilterArguments(new StringValue(format)); var options = new TemplateOptions() { CultureInfo = new CultureInfo("en-US", useUserOverride: false), TimeZone = TimeZoneInfo.Utc }; var context = new TemplateContext(options); + new StringValue(dateTime).TryGetDateTimeInput(new TemplateContext(), out var customDateTime); + var input = new DateTimeValue(customDateTime); + var result = await MiscFilters.Date(input, arguments, context); Assert.Equal(expected, result.ToStringValue()); @@ -268,8 +291,10 @@ public async Task Date(string format, string expected) [Theory] [InlineData("2020-05-18T12:00:00+01:00", "%l:%M%P", "12:00pm")] - [InlineData("2020-05-18T08:00:00+01:00", "%l:%M%P", "8:00am")] - [InlineData("2020-05-18T20:00:00+01:00", "%l:%M%P", "8:00pm")] + [InlineData("2020-05-18T08:00:00+01:00", "%l:%M%P", " 8:00am")] + [InlineData("2020-05-18T20:00:00+01:00", "%l:%M%P", " 8:00pm")] + [InlineData("2020-05-18T20:00:00+01:00", "%-l:%M%P", "8:00pm")] + [InlineData("2020-05-18T20:00:00+01:00", "%I:%M%P", "08:00pm")] [InlineData("2020-05-18T23:59:00+01:00", "%l:%M%P", "11:59pm")] [InlineData("2020-05-18T00:00:00+01:00", "%l:%M%P", "12:00am")] [InlineData("2020-05-18T11:59:00+01:00", "%l:%M%P", "11:59am")] @@ -283,7 +308,7 @@ public async Task Time12hFormatFormDateTimeOffset(string dateTimeOffset, string var result = await MiscFilters.Date(input, arguments, context); - Assert.Equal(expected, result.ToStringValue().Trim()); + Assert.Equal(expected, result.ToStringValue()); } [Theory] diff --git a/Fluid/Filters/MiscFilters.cs b/Fluid/Filters/MiscFilters.cs index 0dc2350f..cae4dc64 100644 --- a/Fluid/Filters/MiscFilters.cs +++ b/Fluid/Filters/MiscFilters.cs @@ -1,14 +1,15 @@ -using System; +using Fluid.Values; +using System; +using System.Buffers; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Net; +using System.Reflection; +using System.Text; using System.Text.Json; -using Fluid.Values; -using TimeZoneConverter; using System.Threading.Tasks; -using System.Text; -using System.IO; -using System.Reflection; +using TimeZoneConverter; namespace Fluid.Filters { @@ -230,9 +231,10 @@ public static ValueTask StripHtml(FluidValue input, FilterArguments return StringValue.Empty; } + var result = ArrayPool.Shared.Rent(html.Length); ; + try { - var result = new char[html.Length]; var cursor = 0; var inside = false; for (var i = 0; i < html.Length; i++) @@ -261,6 +263,10 @@ public static ValueTask StripHtml(FluidValue input, FilterArguments { return new StringValue(String.Empty); } + finally + { + ArrayPool.Shared.Return(result); + } } public static ValueTask Escape(FluidValue input, FilterArguments arguments, TemplateContext context) @@ -293,7 +299,7 @@ public static ValueTask ChangeTimeZone(FluidValue input, FilterArgum return new DateTimeValue(result); } - + // https://docs.ruby-lang.org/en/master/strftime_formatting_rdoc.html public static ValueTask Date(FluidValue input, FilterArguments arguments, TemplateContext context) { if (!input.TryGetDateTimeInput(context, out var value)) @@ -322,6 +328,7 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) var useSpaceForPaddingFlag = false; var upperCaseFlag = false; var useColonsForZeeDirectiveFlag = false; + int? width = null; for (var i = 0; i < format.Length; i++) { @@ -339,12 +346,27 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) } else { + // Zero or more flags (each is a character). switch (c) { case '^': upperCaseFlag = true; continue; case '-': removeLeadingZerosFlag = true; continue; case '_': useSpaceForPaddingFlag = true; continue; case ':': useColonsForZeeDirectiveFlag = true; continue; + default: break; + } + + // An optional width specifier (an integer). + + if (char.IsDigit(c)) + { + width ??= 0; + width = width * 10 + (c - '0'); + continue; + } + + switch (c) + { case 'a': string AbbreviatedDayName() => context.CultureInfo.DateTimeFormat.AbbreviatedDayNames[(int)value.DayOfWeek]; @@ -361,7 +383,6 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) var abbreviatedMonthName = context.CultureInfo.DateTimeFormat.AbbreviatedMonthNames[value.Month - 1]; result.Append(upperCaseFlag ? abbreviatedMonthName.ToUpper() : abbreviatedMonthName); break; - case 'B': { var monthName = context.CultureInfo.DateTimeFormat.MonthNames[value.Month - 1]; @@ -375,34 +396,16 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) result.Append(upperCaseFlag ? value.ToString("F", context.CultureInfo).ToUpper() : value.ToString("F", context.CultureInfo)); break; } - case 'C': result.Append(value.Year / 100); break; - case 'd': - { - var day = value.Day.ToString(context.CultureInfo); - if (useSpaceForPaddingFlag) - { - result.Append(day.PadLeft(2, ' ')); - } - else if (removeLeadingZerosFlag) - { - result.Append(day); - } - else - { - result.Append(day.PadLeft(2, '0')); - } - break; - } + case 'C': result.Append(Format(value.Year / 100, 2)); break; + case 'd': result.Append(Format(value.Day, 2)); break; case 'D': { - var sb = new StringBuilder(); ForStrf(value, "%m/%d/%y", sb); result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); break; } - case 'e': - result.Append(value.Day.ToString(context.CultureInfo).PadLeft(2, ' ')); + case 'e': useSpaceForPaddingFlag = true; result.Append(Format(value.Day, 2)); break; case 'F': { @@ -411,34 +414,60 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); break; } + case 'g': + { + var week = context.CultureInfo.Calendar.GetWeekOfYear(value.DateTime, CalendarWeekRule.FirstFullWeek, DayOfWeek.Monday); + var year = week >= 52 && value.DateTime.Month == 1 ? value.Year - 1 : value.Year; + result.Append(Format(year % 100)); + break; + } + case 'G': + { + var week = context.CultureInfo.Calendar.GetWeekOfYear(value.DateTime, CalendarWeekRule.FirstFullWeek, DayOfWeek.Monday); + var year = week >= 52 && value.DateTime.Month == 1 ? value.Year - 1 : value.Year; + result.Append(Format(year)); + break; + } + case 'h': + ForStrf(value, "%b", result); + break; case 'H': result.Append(value.ToString("HH")); break; - case 'I': result.Append((value.Hour % 12).ToString(context.CultureInfo).PadLeft(2, '0')); break; - case 'j': result.Append(value.DayOfYear.ToString(context.CultureInfo).PadLeft(3, '0')); break; - case 'k': result.Append(value.Hour); break; - case 'l': result.Append(value.ToString("%h", context.CultureInfo).PadLeft(2, ' ')); break; - case 'L': result.Append(value.Millisecond.ToString(context.CultureInfo).PadLeft(3, '0')); break; - case 'm': + case 'I': { - var month = value.Month.ToString(context.CultureInfo); - if (useSpaceForPaddingFlag) - { - result.Append(month.PadLeft(2, ' ')); - } - else if (removeLeadingZerosFlag) + var hour = value.Hour switch { - result.Append(month); - } - else + 0 => 12, + <= 12 => value.Hour, + _ => value.Hour - 12 + }; + + result.Append(Format(hour, 2)); + break; + } + case 'j': result.Append(Format(value.DayOfYear, 3)); break; + case 'k': result.Append(value.Hour); break; + case 'l': + { + useSpaceForPaddingFlag = true; + var hour = value.Hour switch { - result.Append(month.PadLeft(2, '0')); - } + 0 => 12, + <= 12 => value.Hour, + _ => value.Hour - 12 + }; + + result.Append(Format(hour, 2)); break; } + case 'L': result.Append(Format(value.Millisecond, 3)); break; + case 'm': result.Append(Format(value.Month, 2)); break; case 'M': - result.Append(value.Minute.ToString(context.CultureInfo).PadLeft(2, '0')); + result.Append(Format(value.Minute, 2)); break; + case 'n': result.Append(new String('\n', width ?? 1)); break; + case 'N': var v = value.Millisecond.ToString(context.CultureInfo); width ??= 9; result.Append(v.Length >= width ? v.Substring(0, width.Value) : v.PadRight(width.Value, '0')); break; case 'p': result.Append(value.ToString("tt", context.CultureInfo).ToUpper()); break; case 'P': result.Append(value.ToString("tt", context.CultureInfo).ToLower()); break; case 'r': @@ -455,10 +484,48 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); break; } - case 's': result.Append(value.ToUnixTimeSeconds()); break; + case 's': result.Append(Format(value.ToUnixTimeSeconds())); break; case 'S': - result.Append(value.Second.ToString(context.CultureInfo).PadLeft(2, '0')); + result.Append(Format(value.Second, 2)); break; + case 't': result.Append(new String('\t', width ?? 1)); break; + case 'T': + { + var sb = new StringBuilder(); + ForStrf(value, "%H:%M:%S", sb); + result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); + break; + } + case 'u': result.Append(value.DayOfWeek switch { DayOfWeek.Sunday => 7, _ => (int)value.DayOfWeek }); break; + case 'U': + { + var week = context.CultureInfo.Calendar.GetWeekOfYear(value.DateTime, CalendarWeekRule.FirstFullWeek, DayOfWeek.Sunday); + if (week >= 52 && value.DateTime.Month == 1) + { + week = 0; + } + result.Append(Format(week, 2)); + break; + } + case 'v': + { + var sb = new StringBuilder(); + ForStrf(value, "%e-%b-%Y", sb); + result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); + break; + } + case 'V': result.Append(Format(value.DayOfYear / 7 + 1, 2)); break; + case 'w': result.Append(((int)value.DayOfWeek).ToString(context.CultureInfo)); break; + case 'W': + { + var week = context.CultureInfo.Calendar.GetWeekOfYear(value.DateTime, CalendarWeekRule.FirstFullWeek, DayOfWeek.Monday); + if (week >= 52 && value.DateTime.Month == 1) + { + week = 0; + } + result.Append(Format(week, 2)); + break; + } case 'x': { // x is defined as "%m/%d/%y" but it's also supposed to be locale aware, so we are using the @@ -475,29 +542,11 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) result.Append(upperCaseFlag ? value.ToString("t", context.CultureInfo).ToUpper() : value.ToString("t", context.CultureInfo)); break; } - case 'T': - { - var sb = new StringBuilder(); - ForStrf(value, "%H:%M:%S", sb); - result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); - break; - } - case 'u': result.Append((int)value.DayOfWeek); break; - case 'U': result.Append(context.CultureInfo.Calendar.GetWeekOfYear(value.DateTime, CalendarWeekRule.FirstDay, DayOfWeek.Sunday).ToString().PadLeft(2, '0')); break; - case 'v': - { - var sb = new StringBuilder(); - ForStrf(value, "%e-%b-%Y", sb); - result.Append(upperCaseFlag ? sb.ToString().ToUpper() : sb.ToString()); - break; - } - case 'V': result.Append((value.DayOfYear / 7 + 1).ToString(context.CultureInfo).PadLeft(2, '0')); break; - case 'W': result.Append(context.CultureInfo.Calendar.GetWeekOfYear(value.DateTime, CalendarWeekRule.FirstDay, DayOfWeek.Monday).ToString().PadLeft(2, '0')); break; case 'y': - result.Append(value.Year % 100); + result.Append(Format(value.Year % 100, 2)); break; case 'Y': - result.Append(value.Year.ToString().PadLeft(4, '0')); + result.Append(Format(value.Year, 4)); break; case 'z': { @@ -524,6 +573,19 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) useSpaceForPaddingFlag = false; upperCaseFlag = false; useColonsForZeeDirectiveFlag = false; + width = null; + + string Format(long value, int defaultWidth = 0) + { + var stringValue = value.ToString(context.CultureInfo); + + if (removeLeadingZerosFlag) + { + return stringValue.TrimStart('0'); + } + + return stringValue.PadLeft(width == null ? defaultWidth : width.Value, useSpaceForPaddingFlag ? ' ' : '0'); + } } } } From 3d256a8844c15831420c42e7085c8f6fecdad2dc Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Tue, 20 Dec 2022 14:48:11 -0800 Subject: [PATCH 2/2] Use ticks to calculate nanoseconds --- Fluid.Tests/MiscFiltersTests.cs | 4 ++-- Fluid/Filters/MiscFilters.cs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Fluid.Tests/MiscFiltersTests.cs b/Fluid.Tests/MiscFiltersTests.cs index 6fb9c94a..94fb408e 100644 --- a/Fluid.Tests/MiscFiltersTests.cs +++ b/Fluid.Tests/MiscFiltersTests.cs @@ -236,7 +236,7 @@ public async Task EscapeOnce() [InlineData("%-m", "8")] [InlineData("%M", "04")] [InlineData("%n", "\n")] - [InlineData("%N", "123000000")] + [InlineData("%N", "123456800")] // nanoseconds are parsed to the 7th digit [InlineData("%3N", "123")] [InlineData("%1N", "1")] [InlineData("%p", "PM")] @@ -275,7 +275,7 @@ public async Task EscapeOnce() [InlineData("It is %r", "It is 05:04:36 PM")] [InlineData("Chained %z%:z%a%a%^a", "Chained +0800+08:00TueTueTUE")] [InlineData("%Y-%m-%dT%H:%M:%S.%L", "2017-08-01T17:04:36.123")] - public async Task Date(string format, string expected, string dateTime = "2017-08-01T17:04:36.123+08:00") + public async Task Date(string format, string expected, string dateTime = "2017-08-01T17:04:36.123456789+08:00") { var arguments = new FilterArguments(new StringValue(format)); var options = new TemplateOptions() { CultureInfo = new CultureInfo("en-US", useUserOverride: false), TimeZone = TimeZoneInfo.Utc }; diff --git a/Fluid/Filters/MiscFilters.cs b/Fluid/Filters/MiscFilters.cs index cae4dc64..fd0b483e 100644 --- a/Fluid/Filters/MiscFilters.cs +++ b/Fluid/Filters/MiscFilters.cs @@ -467,7 +467,11 @@ void ForStrf(DateTimeOffset value, string format, StringBuilder result) result.Append(Format(value.Minute, 2)); break; case 'n': result.Append(new String('\n', width ?? 1)); break; - case 'N': var v = value.Millisecond.ToString(context.CultureInfo); width ??= 9; result.Append(v.Length >= width ? v.Substring(0, width.Value) : v.PadRight(width.Value, '0')); break; + case 'N': + width ??= 9; + var v = (value.Ticks % 10000000).ToString(context.CultureInfo); + result.Append(v.Length >= width ? v.Substring(0, width.Value) : v.PadRight(width.Value, '0')); + break; case 'p': result.Append(value.ToString("tt", context.CultureInfo).ToUpper()); break; case 'P': result.Append(value.ToString("tt", context.CultureInfo).ToLower()); break; case 'r':