diff --git a/src/Uno.UI.Tests/Windows_Globalization/Given_CalendarFormatter.cs b/src/Uno.UI.Tests/Windows_Globalization/Given_CalendarFormatter.cs index 8f370117cffe..fb5ede29639a 100644 --- a/src/Uno.UI.Tests/Windows_Globalization/Given_CalendarFormatter.cs +++ b/src/Uno.UI.Tests/Windows_Globalization/Given_CalendarFormatter.cs @@ -1,9 +1,9 @@ using System.Globalization; using System.Linq; -using Windows.Globalization.DateTimeFormatting; using FluentAssertions; using FluentAssertions.Execution; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Globalization.DateTimeFormatting; namespace Uno.UI.Tests.Windows_Globalization { @@ -11,22 +11,17 @@ namespace Uno.UI.Tests.Windows_Globalization public class Given_CalendarFormatter { [TestMethod] - [DataRow("day month year", "en-US", "{month.numeric}/{day.integer}/{year.full}")] - [DataRow("day month year", "en-CA", "{year.full}-{month.numeric}-{day.integer(2)}")] - [DataRow("day month year", "en-GB", "{day.integer(2)}/{month.numeric}/{year.full}")] - [DataRow("day month year", "fr-CA", "{year.full}-{month.numeric}-{day.integer(2)}")] - [DataRow("day month year", "fr-FR", "{day.integer(2)}/{month.numeric}/{year.full}")] - [DataRow("day month year", "hu-HU", "{year.full}. {month.numeric}. {day.integer(2)}.")] + [DataRow("day month year", "en-US", "{month.integer}/{day.integer}/{year.full}")] + [DataRow("day month year", "en-CA", "{year.full}-{month.integer(2)}-{day.integer(2)}")] + [DataRow("day month year", "en-GB", "{day.integer(2)}/{month.integer(2)}/{year.full}")] + [DataRow("day month year", "fr-CA", "{year.full}-{month.integer(2)}-{day.integer(2)}")] + [DataRow("day month year", "fr-FR", "{day.integer(2)}/{month.integer(2)}/{year.full}")] + [DataRow("day month year", "hu-HU", "{year.full}. {month.integer(2)}. {day.integer(2)}.")] public void When_UsingVariousLanguages(string format, string language, string expectedPattern) { var sut = new DateTimeFormatter(format, new[] { language }); - var firstPattern = sut.Patterns[0]; - - using var _ = new AssertionScope(); - firstPattern.Should().Be(expectedPattern); - firstPattern.Length.Should().Be(expectedPattern.Length); } #if !NET7_0_OR_GREATER // https://github.com/unoplatform/uno/issues/9080 diff --git a/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeFormatter.cs b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeFormatter.cs index 3f2ffa32422f..bc00ca60796a 100644 --- a/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeFormatter.cs +++ b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeFormatter.cs @@ -16,32 +16,9 @@ namespace Windows.Globalization.DateTimeFormatting; /// public sealed partial class DateTimeFormatter { - private static readonly IReadOnlyList _defaultPatterns; - private static readonly IReadOnlyList _emptyLanguages; - private static readonly IDictionary> _mapCache; - private static readonly IDictionary<(string language, string template), string> _patternsCache; + private readonly CultureInfo _firstCulture; - private readonly CultureInfo? _firstCulture; - private readonly IDictionary[]? _maps; - - static DateTimeFormatter() - { - _mapCache = new Dictionary>(); - _patternsCache = new Dictionary<(string language, string template), string>(); - - _defaultPatterns = new[] - { - "{month.full}‎ ‎{day.integer}‎, ‎{year.full}", - "{day.integer}‎ ‎{month.full}‎, ‎{year.full}", - }; - - _emptyLanguages = Array.Empty(); - - LongDate = new DateTimeFormatter("longdate"); - LongTime = new DateTimeFormatter("longtime"); - ShortDate = new DateTimeFormatter("shortdate"); - ShortTime = new DateTimeFormatter("shorttime"); - } + private readonly PatternRootNode _patternRootNode; /// /// Creates a DateTimeFormatter object that is initialized by a format template string. @@ -72,7 +49,7 @@ public DateTimeFormatter( string formatTemplate, IEnumerable? languages) { - Template = formatTemplate ?? throw new ArgumentNullException(nameof(formatTemplate)); + ArgumentNullException.ThrowIfNull(formatTemplate); if (languages != null) { @@ -81,9 +58,51 @@ public DateTimeFormatter( _firstCulture = new CultureInfo(Languages[0]); - _maps = Languages.SelectToArray(BuildLookup); - - Patterns = BuildPatterns().ToArray(); + try + { + // Template example: + // "year month day dayofweek hour timezone" (that's just an example) + var templateParser = new TemplateParser(formatTemplate); + templateParser.Parse(); + IncludeYear = templateParser.Info.IncludeYear; + IncludeMonth = templateParser.Info.IncludeMonth; + IncludeDay = templateParser.Info.IncludeDay; + IncludeDayOfWeek = templateParser.Info.IncludeDayOfWeek; + IncludeHour = templateParser.Info.IncludeHour; + IncludeMinute = templateParser.Info.IncludeMinute; + IncludeSecond = templateParser.Info.IncludeSecond; + IncludeTimeZone = templateParser.Info.IncludeTimeZone; + IsShortDate = templateParser.Info.IsShortDate; + IsLongDate = templateParser.Info.IsLongDate; + IsShortTime = templateParser.Info.IsShortTime; + IsShortDate = templateParser.Info.IsShortDate; + + // NOTE: We intentionally don't set the user provided template. + // Instead, we parse and re-build the template string. + // That's how WinUI works. + // Basically, the order of tokens in the template string does NOT matter. + // So, this kinda normalizes the order the right way. + Template = BuildTemplate(); + + string patternBuiltFromTemplate = BuildPattern(); + Patterns = [patternBuiltFromTemplate]; + _patternRootNode = new PatternParser(patternBuiltFromTemplate).Parse(); + } + catch (Exception ex) + { + try + { + // Pattern example: + // "Hello {year.full} Hello2 {month.full}" (that's just an example) + _patternRootNode = new PatternParser(formatTemplate).Parse(); + Template = formatTemplate; + Patterns = [formatTemplate]; + } + catch (Exception ex2) + { + throw new AggregateException(ex, ex2); + } + } var calendar = new Calendar(Languages); Calendar = calendar.GetCalendarSystem(); @@ -117,6 +136,10 @@ public DateTimeFormatter( IncludeDay = dayFormat; IncludeDayOfWeek = dayOfWeekFormat; Template = BuildTemplate(); + string patternBuiltFromTemplate = BuildPattern(); + Patterns = [patternBuiltFromTemplate]; + _patternRootNode = new PatternParser(patternBuiltFromTemplate).Parse(); + _firstCulture = new CultureInfo(Languages[0]); // TODO:MZ: Calendar = CalendarIdentifiers.Gregorian; @@ -133,6 +156,10 @@ public DateTimeFormatter( IncludeMinute = minuteFormat; IncludeSecond = secondFormat; Template = BuildTemplate(); + string patternBuiltFromTemplate = BuildPattern(); + Patterns = [patternBuiltFromTemplate]; + _patternRootNode = new PatternParser(patternBuiltFromTemplate).Parse(); + _firstCulture = new CultureInfo(Languages[0]); // TODO:MZ: Calendar = CalendarIdentifiers.Gregorian; @@ -159,6 +186,10 @@ public DateTimeFormatter( IncludeSecond = secondFormat; Languages = languages.ToArray(); Template = BuildTemplate(); + string patternBuiltFromTemplate = BuildPattern(); + Patterns = [patternBuiltFromTemplate]; + _patternRootNode = new PatternParser(patternBuiltFromTemplate).Parse(); + _firstCulture = new CultureInfo(Languages[0]); // TODO:MZ: Calendar = CalendarIdentifiers.Gregorian; @@ -191,6 +222,10 @@ public DateTimeFormatter( Calendar = calendar; Clock = clock; Template = BuildTemplate(); + string patternBuiltFromTemplate = BuildPattern(); + Patterns = [patternBuiltFromTemplate]; + _patternRootNode = new PatternParser(patternBuiltFromTemplate).Parse(); + _firstCulture = new CultureInfo(Languages[0]); } /// @@ -243,6 +278,16 @@ public DateTimeFormatter( /// public YearFormat IncludeYear { get; } + internal TimeZoneFormat IncludeTimeZone { get; } + + internal bool IsShortTime { get; } + + internal bool IsLongTime { get; } + + internal bool IsShortDate { get; } + + internal bool IsLongDate { get; } + /// /// Gets the priority list of language identifiers that is used when formatting dates and times. /// @@ -251,12 +296,12 @@ public DateTimeFormatter( /// /// Gets the DateTimeFormatter object that formats dates according to the user's choice of long date pattern. /// - public static DateTimeFormatter LongDate { get; } + public static DateTimeFormatter LongDate { get; } = new DateTimeFormatter("longdate"); /// /// Gets the DateTimeFormatter object that formats times according to the user's choice of long time pattern. /// - public static DateTimeFormatter LongTime { get; } + public static DateTimeFormatter LongTime { get; } = new DateTimeFormatter("longtime"); /// /// Gets or sets the numbering system that is used to format dates and times. @@ -266,7 +311,7 @@ public DateTimeFormatter( /// /// Gets the patterns corresponding to this template that are used when formatting dates and times. /// - public IReadOnlyList Patterns { get; } = _defaultPatterns; + public IReadOnlyList Patterns { get; } /// /// Gets the geographic region that was most recently used to format dates and times. @@ -281,127 +326,27 @@ public DateTimeFormatter( /// /// Gets the DateTimeFormatter object that formats dates according to the user's choice of short date pattern. /// - public static DateTimeFormatter ShortDate { get; } + public static DateTimeFormatter ShortDate { get; } = new DateTimeFormatter("shortdate"); /// /// Gets the DateTimeFormatter object that formats times according to the user's choice of short time pattern. /// - public static DateTimeFormatter ShortTime { get; } + public static DateTimeFormatter ShortTime { get; } = new DateTimeFormatter("shorttime"); /// /// Gets a string representation of this format template. /// public string Template { get; } - private IDictionary BuildLookup(string language) - { - if (_mapCache.TryGetValue(language, out var map)) - { - return map; - } - - var info = new CultureInfo(language).DateTimeFormat; - - map = new Dictionary - { - // Predefined patterns - { "longdate" , info.LongDatePattern } , - { "shortdate" , info.ShortDatePattern } , - { "longtime" , info.LongTimePattern } , - { "shorttime" , info.ShortTimePattern } , - - // Compound patterns - { "dayofweek day month year" , info.FullDateTimePattern } , - { "dayofweek day month" , "D" } , - { "day month year" , info.ShortDatePattern } , - { "day month.full year" , info.ShortDatePattern } , - { "day month" , info.MonthDayPattern } , - { "month year" , info.YearMonthPattern } , - { "hour minute second" , info.LongTimePattern }, - { "hour minute" , info.ShortTimePattern }, - //{ "year month day hour" , "" }, - - // Day of week formats - { "dayofweek" , "dddd" } , - { "dayofweek.full" , "dddd" } , - { "dayofweek.abbreviated" , "ddd" } , - { "dayofweek.abbreviated(1)" , "dd" } , - { "dayofweek.abbreviated(2)" , "ddd" } , - { "dayofweek.solo.full" , "dddd" } , - { "dayofweek.solo.abbreviated" , "ddd" } , - - // Day formats - { "day" , "%d" } , - { "day.integer" , "%d" }, - { "day.integer(1)" , "%d" }, - { "day.integer(2)" , "dd" }, - - // Month formats - { "month" , "MMMM" } , - { "month.full" , "MMMM" } , - { "month.abbreviated" , "MMM" } , - { "month.abbreviated(1)" , "%M" } , - { "month.abbreviated(2)" , "MMM" } , - { "month.numeric" , "%M" } , - { "month.integer" , "%M" } , - { "month.integer(1)" , "%M" } , - { "month.integer(2)" , "MM" } , - { "month.solo.full" , "MMMM" } , - { "month.solo.abbreviated" , "MMM" } , - - // Year formats - { "year" , "yyyy" } , - { "year.full" , "yyyy" } , - { "year.abbreviated" , "yy" } , - { "year.abbreviated(1)" , "%y" } , - { "year.abbreviated(2)" , "yy" } , - - // Hour formats - { "hour" , "%H" } , - { "hour.integer" , "%H" } , - { "hour.integer(1)" , "%h" } , - { "hour.integer(2)" , "HH" } , - - // Period (AM/PM) formats - { "period" , "tt" } , - { "period.full" , "tt" } , - { "period.abbreviated" , "tt" } , - { "period.abbreviated(1)" , "t" } , - { "period.abbreviated(2)" , "tt" } , - - // Minute formats - { "minute" , "%m" } , - { "minute.integer" , "%m" } , - { "minute.integer(1)" , "%m" } , - { "minute.integer(2)" , "mm" } , - - // Second formats - { "second" , "%s" } , - { "second.integer" , "%s" } , - { "second.integer(1)" , "%s" } , - { "second.integer(2)" , "ss" } , - - // Timezone formats - { "timezone" , "%z" } , - { "timezone.full" , "zzz" } , - { "timezone.abbreviated" , "zz" } , - { "timezone.abbreviated(1)" , "%z" } , - { "timezone.abbreviated(2)" , "zz" } - }; - - return _mapCache[language] = map; - } - public string Format(DateTimeOffset value) { - var format = GetSystemTemplate(); try { - return value.ToString(format, _firstCulture!.DateTimeFormat); + return _patternRootNode.Format(value, _firstCulture, isTwentyFourHours: Clock == ClockIdentifiers.TwentyFourHour); } catch (Exception e) { - return format + " : " + e.Message; + return Template + " : " + e.Message; } } @@ -411,97 +356,464 @@ public string Format(DateTimeOffset datetime, string timeZoneId) throw new NotSupportedException(); } - private string GetSystemTemplate() - { - var result = Template.Replace("{", "").Replace("}", ""); + private static string ToTemplateString(YearFormat yearFormat) + => yearFormat switch + { + YearFormat.None => string.Empty, + YearFormat.Default => "year", + YearFormat.Full => "year.full", + YearFormat.Abbreviated => "year.abbreviated", + _ => throw new ArgumentOutOfRangeException(nameof(yearFormat)), + }; - var map = _maps![0]; + private static string ToTemplateString(MonthFormat monthFormat) + => monthFormat switch + { + MonthFormat.None => string.Empty, + MonthFormat.Default => "month", + MonthFormat.Abbreviated => "month.abbreviated", + MonthFormat.Full => "month.full", + MonthFormat.Numeric => "month.integer", + _ => throw new ArgumentOutOfRangeException(nameof(monthFormat)), + }; - var sortedKeys = map.Keys.OrderByDescending(k => k.Length); + private static string ToTemplateString(DayFormat dayFormat) + => dayFormat switch + { + DayFormat.None => string.Empty, + DayFormat.Default => "day", + _ => throw new ArgumentOutOfRangeException(nameof(dayFormat)), + }; - foreach (var key in sortedKeys) + private static string ToTemplateString(DayOfWeekFormat dayOfWeekFormat) + => dayOfWeekFormat switch { - result = result.Replace(key, map[key]); - } + DayOfWeekFormat.None => string.Empty, + DayOfWeekFormat.Default => "dayofweek", + DayOfWeekFormat.Abbreviated => "dayofweek.abbreviated", + DayOfWeekFormat.Full => "dayofweek.full", + _ => throw new ArgumentOutOfRangeException(nameof(dayOfWeekFormat)), + }; + + private static string ToTemplateString(HourFormat hourFormat) + => hourFormat switch + { + HourFormat.None => string.Empty, + HourFormat.Default => "hour", + _ => throw new ArgumentOutOfRangeException(nameof(hourFormat)), + }; + + private static string ToTemplateString(MinuteFormat minuteFormat) + => minuteFormat switch + { + MinuteFormat.None => string.Empty, + MinuteFormat.Default => "minute", + _ => throw new ArgumentOutOfRangeException(nameof(minuteFormat)), + }; + + private static string ToTemplateString(SecondFormat secondFormat) + => secondFormat switch + { + SecondFormat.None => string.Empty, + SecondFormat.Default => "second", + _ => throw new ArgumentOutOfRangeException(nameof(secondFormat)), + }; + + private static string ToTemplateString(TimeZoneFormat timeZoneFormat) + => timeZoneFormat switch + { + TimeZoneFormat.None => string.Empty, + TimeZoneFormat.Default => "timezone", + TimeZoneFormat.Abbreviated => "timezone.abbreviated", + TimeZoneFormat.Full => "timezone.full", + _ => throw new ArgumentOutOfRangeException(nameof(timeZoneFormat)), + }; - if (result.Contains("h") && Clock == ClockIdentifiers.TwentyFourHour) + private string BuildTemplate() + { + var templateBuilder = new StringBuilder(); + if (IsLongDate) + Append("longdate"); + else if (IsShortDate) + Append("shortdate"); + else { - result = result.Replace("h", "H"); + Append(ToTemplateString(IncludeYear)); + Append(ToTemplateString(IncludeMonth)); + Append(ToTemplateString(IncludeDay)); + Append(ToTemplateString(IncludeDayOfWeek)); } - else if (result.Contains("H") && Clock == ClockIdentifiers.TwelveHour) + + if (IsLongTime) + Append("longtime"); + else if (IsShortTime) + Append("shorttime"); + else { - result = result.Replace("H", "h"); + Append(ToTemplateString(IncludeHour)); + Append(ToTemplateString(IncludeMinute)); + Append(ToTemplateString(IncludeSecond)); + Append(ToTemplateString(IncludeTimeZone)); } - return result; - } + return templateBuilder.ToString(); - private static readonly (Regex pattern, string replacement)[] PatternsReplacements = - new (string pattern, string replacement)[] + void Append(string value) + { + if (value.Length == 0) + { + return; + } + + if (templateBuilder.Length != 0) { - (@"\bMMMM\b", "{month.full}"), - (@"\bMMM\b", "{month.abbreviated}"), - (@"\bMM\b", "{month.numeric}"), - (@"%M\b", "{month.numeric}"), - (@"\bM\b", "{month.numeric}"), - (@"\bdddd\b", "{dayofweek.full}"), - (@"\bddd\b", "{dayofweek.abbreviated}"), - (@"\byyyy\b", "{year.full}"), - (@"\byy\b", "{year.abbreviated}"), - (@"\b(z|zz)\b", "{timezone.abbreviated}"), - (@"\byyyy\b", "{year.full}"), - (@"\bMMMM\b", "{month.full}"), - (@"\bdd\b", "{day.integer(2)}"), - (@"%d\b", "{day.integer}"), - (@"\bd\b", "{day.integer}"), - (@"\bzzz\b", "{timezone.full}"), - (@"\bzz\b", "{timezone.abbreviated}"), - (@"%z\b", "{timezone}"), - (@"\b(HH|hh|H|h)\b", "{hour}"), - (@"\b(mm)\b", "{minute.integer(2)}"), - (@"\b(m)\b", "{minute.integer}"), - (@"\b(mm|m)\b", "{minute}"), - (@"\b(ss|s)\b", "{second}"), - (@"\btt\b", "{period.abbreviated}"), + templateBuilder.Append(' '); } - .SelectToArray(x => - (new Regex(x.pattern, RegexOptions.CultureInvariant | RegexOptions.Compiled), - x.replacement)); - private IEnumerable BuildPatterns() + templateBuilder.Append(value); + } + } + + private string BuildPattern() { - var format = Template; - string? sanitizedFormat = default; + string datePattern; + if (IsLongDate) + { + datePattern = _firstCulture.DateTimeFormat.LongDatePattern; + } + else if (IsShortDate) + { + datePattern = _firstCulture.DateTimeFormat.ShortDatePattern; + } + else + { + // NOTE: The original order that was specified in the template string does NOT matter. + // That's why all we need to care about is the values of the mentioned properties. + // However, we should actually be checking the actual enum value and not just being "None" or not. + // But the actual correct implementation that will 100% match WinUI isn't yet clear. + // This is a best effort approach. + bool hasYear = IncludeYear != YearFormat.None; + bool hasMonth = IncludeMonth != MonthFormat.None; + bool hasDay = IncludeDay != DayFormat.None; + bool hasDayOfWeek = IncludeDayOfWeek != DayOfWeekFormat.None; + + if (hasYear && hasMonth && hasDay && hasDayOfWeek) + { + datePattern = _firstCulture.DateTimeFormat.LongDatePattern; + } + else if (hasYear && hasMonth && hasDay) + { + datePattern = _firstCulture.DateTimeFormat.ShortDatePattern; + } + else if (hasYear && hasMonth && !hasDayOfWeek) + { + datePattern = _firstCulture.DateTimeFormat.YearMonthPattern; + } + else if (hasYear && !hasMonth && !hasDay && !hasDayOfWeek) + { + datePattern = "yyyy"; + } + else if (hasMonth && hasDay && !hasYear && !hasDayOfWeek) + { + datePattern = _firstCulture.DateTimeFormat.MonthDayPattern; + } + else + { + // Fallback case. + // Add more cases as they arise. + datePattern = _firstCulture.DateTimeFormat.LongDatePattern; + } + } - for (var i = 0; i < Languages.Count; i++) + string timePattern; + if (IsLongTime) + { + timePattern = _firstCulture.DateTimeFormat.LongTimePattern; + } + else if (IsShortTime) { - var language = Languages[i]; - if (_patternsCache.TryGetValue((language, format), out var pattern)) + timePattern = _firstCulture.DateTimeFormat.ShortTimePattern; + } + else + { + // NOTE: The original order that was specified in the template string does NOT matter. + // That's why all we need to care about is the values of the mentioned properties. + bool hasHour = IncludeHour != HourFormat.None; + bool hasMinute = IncludeMinute != MinuteFormat.None; + bool hasSecond = IncludeSecond != SecondFormat.None; + bool hasTimeZone = IncludeTimeZone != TimeZoneFormat.None; + + if (hasHour && hasMinute && hasSecond) + { + timePattern = _firstCulture.DateTimeFormat.LongTimePattern; + } + else if (hasHour && hasMinute) { - yield return pattern; + timePattern = _firstCulture.DateTimeFormat.ShortTimePattern; + } + else if (hasHour) + { + timePattern = "h"; + } + else if (!hasHour && !hasMinute && !hasSecond) + { + timePattern = string.Empty; } else { - var map = _maps![i]; - sanitizedFormat ??= format - .Replace("{", "") - .Replace("}", ""); - if (map.TryGetValue(sanitizedFormat, out var r)) + // Shouldn't really be reachable. But a fallback in place just in case. + timePattern = _firstCulture.DateTimeFormat.LongTimePattern; + } + + if (hasTimeZone) + { + if (timePattern.Length == 0) { - foreach (var p in PatternsReplacements) + timePattern = "zzz"; + } + else + { + timePattern = $"{timePattern} zzz"; + } + } + } + + string finalSystemFormat; + if (datePattern.Length > 0 && timePattern.Length > 0) + { + finalSystemFormat = $"{datePattern} {timePattern}"; + } + else if (datePattern.Length > 0) + { + finalSystemFormat = datePattern; + } + else + { + finalSystemFormat = timePattern; + } + + return ConstructPattern(finalSystemFormat); + + void AddToBuilder(StringBuilder builder, char lastChar, int count) + { + // Best effort implementation. + switch (lastChar) + { + case 'g': + while (count >= 2) + { + builder.Append("{era.abbreviated}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{era.abbreviated}"); + count -= 1; + } + + break; + + case 'y': + while (count >= 5) { - r = p.pattern.Replace(r, p.replacement); + builder.Append("{year.full(5)}"); + count -= 5; } - yield return _patternsCache[(language, format)] = r; + while (count >= 4) + { + builder.Append("{year.full}"); + count -= 4; + } + + while (count >= 3) + { + builder.Append("{year.full(3)}"); + count -= 3; + } + + while (count >= 2) + { + builder.Append("{year.full(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{year.full(1)}"); + count -= 1; + } + + break; + + case 'M': + while (count >= 4) + { + builder.Append("{month.full}"); + count -= 4; + } + + while (count >= 3) + { + builder.Append("{month.abbreviated}"); + count -= 3; + } + + while (count >= 2) + { + builder.Append("{month.integer(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{month.integer}"); + count -= 1; + } + + break; + + case 'd': + while (count >= 4) + { + builder.Append("{dayofweek.full}"); + count -= 4; + } + + while (count >= 3) + { + builder.Append("{dayofweek.abbreviated}"); + count -= 3; + } + + while (count >= 2) + { + builder.Append("{day.integer(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{day.integer}"); + count -= 1; + } + + break; + + case 't': + while (count >= 2) + { + builder.Append("{period.abbreviated(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{period.abbreviated(1)}"); + count -= 1; + } + + break; + + case 'h' or 'H': + + while (count >= 2) + { + builder.Append("{hour.integer(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{hour.integer(1)}"); + count -= 1; + } + + break; + + case 'm': + + while (count >= 2) + { + builder.Append("{minute.integer(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{minute.integer(1)}"); + count -= 1; + } + + break; + + case 's': + + while (count >= 2) + { + builder.Append("{second.integer(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{second.integer(1)}"); + count -= 1; + } + + break; + + case 'z': + while (count >= 3) + { + builder.Append("{timezone.full}"); + count -= 3; + } + + while (count >= 2) + { + builder.Append("{timezone.abbreviated(2)}"); + count -= 2; + } + + while (count >= 1) + { + builder.Append("{timezone.abbreviated(1)}"); + count -= 1; + } + + break; + + default: + builder.Append(lastChar, count); + break; + } + } + + string ConstructPattern(string str) + { + var builder = new StringBuilder(); + char lastChar = str[0]; + int count = 1; + for (int i = 1; i < str.Length; i++) + { + if (lastChar != str[i]) + { + AddToBuilder(builder, lastChar, count); + + lastChar = str[i]; + count = 1; + } + else + { + count++; } } + + AddToBuilder(builder, lastChar, count); + return builder.ToString(); } } - private string BuildTemplate() - { - var templateBuilder = new StringBuilder(); - return templateBuilder.ToString(); - } } diff --git a/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimePatternParser/PatternNodes.cs b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimePatternParser/PatternNodes.cs new file mode 100644 index 000000000000..a2c3cf8d6557 --- /dev/null +++ b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimePatternParser/PatternNodes.cs @@ -0,0 +1,429 @@ +#nullable enable + +using System; +using System.Globalization; +using System.Text; + +namespace Windows.Globalization.DateTimeFormatting; + +// ::= [] [] | +// [] +internal sealed class PatternRootNode +{ + public PatternRootNode( + PatternLiteralTextNode? optionalPrefixLiteralText, + PatternDateTimeNode dateTimeNode, + PatternLiteralTextNode? optionalSuffixLiteralText) + { + OptionalPrefixLiteralText = optionalPrefixLiteralText; + DateTimeNode = dateTimeNode; + OptionalSuffixLiteralText = optionalSuffixLiteralText; + } + + public PatternRootNode( + PatternLiteralTextNode? optionalPrefixLiteralText, + PatternDateTimeNode dateTimeNode, + PatternRootNode? optionalSuffixPattern) + { + OptionalPrefixLiteralText = optionalPrefixLiteralText; + DateTimeNode = dateTimeNode; + OptionalSuffixPattern = optionalSuffixPattern; + } + + public PatternLiteralTextNode? OptionalPrefixLiteralText { get; } + public PatternDateTimeNode DateTimeNode { get; } + + // Either both are null, or only one is null. + // The two cannot be non-null at the same time + public PatternLiteralTextNode? OptionalSuffixLiteralText { get; } + public PatternRootNode? OptionalSuffixPattern { get; } + + internal string Format(DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + var builder = new StringBuilder(); + Format(builder, dateTime, culture, isTwentyFourHours); + return builder.ToString(); + } + + internal void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + OptionalPrefixLiteralText?.Format(builder, dateTime, culture, isTwentyFourHours); + DateTimeNode.Format(builder, dateTime, culture, isTwentyFourHours); + OptionalSuffixLiteralText?.Format(builder, dateTime, culture, isTwentyFourHours); + OptionalSuffixPattern?.Format(builder, dateTime, culture, isTwentyFourHours); + } +} + +// ::= + +// ::= [^{}] | "{openbrace}" | "{closebrace}" +internal sealed class PatternLiteralTextNode +{ + public PatternLiteralTextNode(string text) + { + Text = text; + } + + public string Text { get; } + + internal void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + => builder.Append(Text); +} + +// ::= | | | | | +// | | | | +internal abstract class PatternDateTimeNode +{ + internal abstract void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours); +} + +// ::= "{era.abbreviated" [] "}" +internal sealed class PatternEraNode : PatternDateTimeNode +{ + public PatternEraNode(int? idealLength) + { + IdealLength = idealLength; + } + + // Era is always "era.abbreviated", so no need to distinguish. + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + // C# can't represent negative dates (i.e, before christ). + // So, looks like Era will always be "AD" (Anno Domini). + builder.Append(IdealLength.HasValue && IdealLength.Value < 4 ? "AD" : "A.D."); + } +} + +// ::= "{year.full" [] "}" | +// "{year.abbreviated" [] "}" | +internal sealed class PatternYearNode : PatternDateTimeNode +{ + internal enum YearKind + { + Full, + Abbreviated + } + + public PatternYearNode(YearKind kind, int? idealLength) + { + Kind = kind; + IdealLength = idealLength; + } + + public YearKind Kind { get; } + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (Kind == YearKind.Full) + { + if (IdealLength.HasValue) + { + builder.Append(dateTime.Year.ToString(new string('0', IdealLength.Value), culture)); + } + else + { + builder.Append(dateTime.Year); + } + } + else if (Kind == YearKind.Abbreviated) + { + if (IdealLength.HasValue) + { + // TODO: This might not always be the right approach for all calendars. + // This implementation assumes gregorian calendar. The "correct" approach isn't yet known. + builder.Append((dateTime.Year % (10 * IdealLength.Value)).ToString(new string('0', IdealLength.Value), culture)); + } + else + { + builder.Append((dateTime.Year % 100).ToString("00", culture)); + } + } + } +} + +// ::= "{month.full}" | +// "{month.solo.full}" | +// "{month.abbreviated" [] "}" +// "{month.solo.abbreviated" [] "}" +// "{month.integer" [] "}" +internal sealed class PatternMonthNode : PatternDateTimeNode +{ + internal enum MonthKind + { + Full, + SoloFull, + Abbreviated, + SoloAbbreviated, + Integer, + } + + public PatternMonthNode(MonthKind kind, int? idealLength = null) + { + Kind = kind; + IdealLength = idealLength; + } + + public MonthKind Kind { get; } + + // Always null for full and solo.full + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (Kind == MonthKind.Full) + { + builder.Append(dateTime.ToString("MMMM", culture)); + } + else if (Kind == MonthKind.SoloFull) + { + builder.Append(dateTime.ToString("MMMM", culture)); + } + else if (Kind == MonthKind.Abbreviated) + { + builder.Append(dateTime.ToString("MMM", culture)); + } + else if (Kind == MonthKind.SoloAbbreviated) + { + builder.Append(dateTime.ToString("MMM", culture)); + } + else if (Kind == MonthKind.Integer) + { + if (IdealLength.HasValue) + { + var idealLength = IdealLength.Value; + if (idealLength >= 2) + { + builder.Append(dateTime.ToString("MM", culture)); + } + else + { + builder.Append(dateTime.Month); + } + } + else + { + builder.Append(dateTime.Month); + } + } + } +} + +// ::= "{dayofweek.full}" | +// "{dayofweek.solo.full}" | +// "{dayofweek.abbreviated" [] "}" +// "{dayofweek.solo.abbreviated" [] "}" +internal sealed class PatternDayOfWeekNode : PatternDateTimeNode +{ + internal enum DayOfWeekKind + { + Full, + SoloFull, + Abbreviated, + SoloAbbreviated, + } + + public PatternDayOfWeekNode(DayOfWeekKind kind, int? idealLength = null) + { + Kind = kind; + IdealLength = idealLength; + } + + public DayOfWeekKind Kind { get; } + + // Always null for full and solo.full + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (Kind == DayOfWeekKind.Full) + { + builder.Append(dateTime.ToString("dddd", culture)); + } + else if (Kind == DayOfWeekKind.SoloFull) + { + builder.Append(dateTime.ToString("dddd", culture)); + } + else if (Kind == DayOfWeekKind.Abbreviated) + { + builder.Append(dateTime.ToString("ddd", culture)); + } + else if (Kind == DayOfWeekKind.SoloAbbreviated) + { + builder.Append(dateTime.ToString("ddd", culture)); + } + } +} + +// ::= "{day.integer" [] "}" +internal sealed class PatternDayNode : PatternDateTimeNode +{ + public PatternDayNode(int? idealLength) + { + IdealLength = idealLength; + } + + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (IdealLength.HasValue) + { + builder.Append(dateTime.Day.ToString(new string('0', IdealLength.Value), culture)); + } + else + { + builder.Append(dateTime.Day); + } + } +} + +// ::= "{period.abbreviated" [] "}" +internal sealed class PatternPeriodNode : PatternDateTimeNode +{ + public PatternPeriodNode(int? idealLength) + { + IdealLength = idealLength; + } + + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (isTwentyFourHours) + { + return; + } + + string period = dateTime.ToString("tt", culture); + if (IdealLength.HasValue && IdealLength.Value < period.Length) + { + builder.Append(period.Substring(0, IdealLength.Value)); + } + else + { + builder.Append(period); + } + } +} + +// ::= "{hour.integer" [] "}" +internal sealed class PatternHourNode : PatternDateTimeNode +{ + public PatternHourNode(int? idealLength) + { + IdealLength = idealLength; + } + + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + int hour = dateTime.Hour; + if (!isTwentyFourHours && hour > 12) + { + hour = hour - 12; + } + + if (IdealLength.HasValue) + { + builder.Append(hour.ToString(new string('0', IdealLength.Value), culture)); + } + else + { + builder.Append(hour); + } + } +} + +// ::= "{minute.integer" [] "}" +internal sealed class PatternMinuteNode : PatternDateTimeNode +{ + public PatternMinuteNode(int? idealLength) + { + IdealLength = idealLength; + } + + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (IdealLength.HasValue) + { + builder.Append(dateTime.Minute.ToString(new string('0', IdealLength.Value), culture)); + } + else + { + builder.Append(dateTime.Minute); + } + } +} + +// ::= "{second.integer" [] "}" +internal sealed class PatternSecondNode : PatternDateTimeNode +{ + public PatternSecondNode(int? idealLength) + { + IdealLength = idealLength; + } + + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + if (IdealLength.HasValue) + { + builder.Append(dateTime.Second.ToString(new string('0', IdealLength.Value), culture)); + } + else + { + builder.Append(dateTime.Second); + } + } +} + +// ::= "{timezone.full}" | +// "{timezone.abbreviated" [] "}" +internal sealed class PatternTimeZoneNode : PatternDateTimeNode +{ + internal enum TimeZoneKind + { + Full, + Abbreviated, + } + + public PatternTimeZoneNode(TimeZoneKind kind, int? idealLength = null) + { + Kind = kind; + IdealLength = idealLength; + } + + public TimeZoneKind Kind { get; } + + // Always null for full + public int? IdealLength { get; } + + internal override void Format(StringBuilder builder, DateTimeOffset dateTime, CultureInfo culture, bool isTwentyFourHours) + { + // Important: dateTime parameter shouldn't be used here. + // WinUI uses the local time zone info. + // The offset part of DateTimeOffset will actually be lost when marshalling to WinUI. + // The marshalling of DateTimeOffset to C++ is just a simple "UniversalTime" that doesn't have + // information about time zones. It's simply UtcTicks - ManagedUtcTicksAtNativeZero (which is 504911232000000000) + if (Kind == TimeZoneKind.Full) + { + builder.Append(TimeZoneInfo.Local.StandardName); + } + else if (Kind == TimeZoneKind.Abbreviated) + { + // Note: Couldn't notice any behavior difference on WinUI using different values of ideal length. + // So it's unused for now until it's known how the value should be used and how it affects WinUI behavior. + builder.Append("GMT+"); + builder.Append(TimeZoneInfo.Local.BaseUtcOffset.TotalHours); + } + } +} + +// ::= "(" ")" +// ::= "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" diff --git a/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimePatternParser/PatternParser.cs b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimePatternParser/PatternParser.cs new file mode 100644 index 000000000000..f5c33414fbcc --- /dev/null +++ b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimePatternParser/PatternParser.cs @@ -0,0 +1,514 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Windows.Globalization.DateTimeFormatting; + +// We are kinda combining a lexer and a parser here as the grammar is simple enough. +internal sealed class PatternParser +{ + private int _index; + private string _pattern; + + public bool Completed => _index >= _pattern.Length; + + public PatternParser(string pattern) + { + _pattern = pattern; + } + + public PatternRootNode Parse() + { + PatternLiteralTextNode? prefixLiteralText = ParseLiteralText(); + PatternDateTimeNode dateTimeNode = ParseDateTimePattern(); + object? suffix = TryParseLiteralTextOrPattern(); + if (suffix is null) + { + return new PatternRootNode(prefixLiteralText, dateTimeNode, (PatternLiteralTextNode?)null); + } + else if (suffix is PatternLiteralTextNode suffixLiteralText) + { + return new PatternRootNode(prefixLiteralText, dateTimeNode, suffixLiteralText); + } + else if (suffix is PatternRootNode suffixPattern) + { + return new PatternRootNode(prefixLiteralText, dateTimeNode, suffixPattern); + } + else + { + throw new InvalidOperationException("Unreachable."); + } + } + + private object? TryParseLiteralTextOrPattern() + { + PatternLiteralTextNode? prefixLiteralText = ParseLiteralText(); + + if (Completed) + { + return prefixLiteralText; + } + + PatternDateTimeNode dateTimeNode = ParseDateTimePattern(); + object? suffix = TryParseLiteralTextOrPattern(); + if (suffix is null) + { + return new PatternRootNode(prefixLiteralText, dateTimeNode, (PatternLiteralTextNode?)null); + } + else if (suffix is PatternLiteralTextNode suffixLiteralText) + { + return new PatternRootNode(prefixLiteralText, dateTimeNode, suffixLiteralText); + } + else if (suffix is PatternRootNode suffixPattern) + { + return new PatternRootNode(prefixLiteralText, dateTimeNode, suffixPattern); + } + else + { + throw new InvalidOperationException("Unreachable."); + } + } + + // ::= | | | | | + // | | | | + private PatternDateTimeNode ParseDateTimePattern() + { + if (Completed) + { + throw new ArgumentException($"Failed to parse date time pattern. Already reached the end of the pattern '{_pattern}'."); + } + + if (TryParseEra(out var era)) + { + return era; + } + else if (TryParseYear(out var year)) + { + return year; + } + else if (TryParseMonth(out var month)) + { + return month; + } + else if (TryParseDay(out var day)) + { + return day; + } + else if (TryParseDayOfWeek(out var dayOfWeek)) + { + return dayOfWeek; + } + else if (TryParsePeriod(out var period)) + { + return period; + } + else if (TryParseHour(out var hour)) + { + return hour; + } + else if (TryParseMinute(out var minute)) + { + return minute; + } + else if (TryParseSecond(out var second)) + { + return second; + } + else if (TryParseTimeZone(out var timeZone)) + { + return timeZone; + } + + throw new ArgumentException($"Failed to parse date time pattern component for pattern '{_pattern}'."); + } + + private bool TryParseEra([NotNullWhen(true)] out PatternEraNode? era) + { + if (_pattern[_index] != '{') + { + era = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "era.abbreviated")) + { + _index += "{era.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + era = new PatternEraNode(idealLength); + return true; + } + + era = null; + return false; + } + + private bool TryParseYear([NotNullWhen(true)] out PatternYearNode? year) + { + if (_pattern[_index] != '{') + { + year = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "year.full")) + { + _index += "{year.full".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + year = new PatternYearNode(PatternYearNode.YearKind.Full, idealLength); + return true; + } + else if (IsWordAfterOpenBrace(_index, "year.abbreviated")) + { + _index += "{year.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + year = new PatternYearNode(PatternYearNode.YearKind.Abbreviated, idealLength); + return true; + } + + year = null; + return false; + } + + private bool TryParseMonth([NotNullWhen(true)] out PatternMonthNode? month) + { + if (_pattern[_index] != '{') + { + month = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "month.full")) + { + _index += "{month.full".Length; + ExpectCharacterAndAdvance('}'); + month = new PatternMonthNode(PatternMonthNode.MonthKind.Full); + return true; + } + else if (IsWordAfterOpenBrace(_index, "month.solo.full")) + { + _index += "{month.solo.full".Length; + ExpectCharacterAndAdvance('}'); + month = new PatternMonthNode(PatternMonthNode.MonthKind.SoloFull); + return true; + } + else if (IsWordAfterOpenBrace(_index, "month.abbreviated")) + { + _index += "{month.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + month = new PatternMonthNode(PatternMonthNode.MonthKind.Abbreviated, idealLength); + return true; + } + else if (IsWordAfterOpenBrace(_index, "month.solo.abbreviated")) + { + _index += "{month.solo.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + month = new PatternMonthNode(PatternMonthNode.MonthKind.SoloAbbreviated, idealLength); + return true; + } + else if (IsWordAfterOpenBrace(_index, "month.integer")) + { + _index += "{month.integer".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + month = new PatternMonthNode(PatternMonthNode.MonthKind.Integer, idealLength); + return true; + } + + month = null; + return false; + } + + private bool TryParseDayOfWeek([NotNullWhen(true)] out PatternDayOfWeekNode? dayOfWeek) + { + if (_pattern[_index] != '{') + { + dayOfWeek = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "dayofweek.full")) + { + _index += "{dayofweek.full".Length; + ExpectCharacterAndAdvance('}'); + dayOfWeek = new PatternDayOfWeekNode(PatternDayOfWeekNode.DayOfWeekKind.Full); + return true; + } + else if (IsWordAfterOpenBrace(_index, "dayofweek.solo.full")) + { + _index += "{dayofweek.solo.full".Length; + ExpectCharacterAndAdvance('}'); + dayOfWeek = new PatternDayOfWeekNode(PatternDayOfWeekNode.DayOfWeekKind.SoloFull); + return true; + } + else if (IsWordAfterOpenBrace(_index, "dayofweek.abbreviated")) + { + _index += "{dayofweek.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + dayOfWeek = new PatternDayOfWeekNode(PatternDayOfWeekNode.DayOfWeekKind.Abbreviated, idealLength); + return true; + } + else if (IsWordAfterOpenBrace(_index, "dayofweek.solo.abbreviated")) + { + _index += "{dayofweek.solo.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + dayOfWeek = new PatternDayOfWeekNode(PatternDayOfWeekNode.DayOfWeekKind.SoloAbbreviated, idealLength); + return true; + } + + dayOfWeek = null; + return false; + } + + private bool TryParseDay([NotNullWhen(true)] out PatternDayNode? day) + { + if (_pattern[_index] != '{') + { + day = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "day.integer")) + { + _index += "{day.integer".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + day = new PatternDayNode(idealLength); + return true; + } + + day = null; + return false; + } + + private bool TryParsePeriod([NotNullWhen(true)] out PatternPeriodNode? period) + { + if (_pattern[_index] != '{') + { + period = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "period.abbreviated")) + { + _index += "{period.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + period = new PatternPeriodNode(idealLength); + return true; + } + + period = null; + return false; + } + + private bool TryParseHour([NotNullWhen(true)] out PatternHourNode? hour) + { + if (_pattern[_index] != '{') + { + hour = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "hour.integer")) + { + _index += "{hour.integer".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + hour = new PatternHourNode(idealLength); + return true; + } + + hour = null; + return false; + } + + private bool TryParseMinute([NotNullWhen(true)] out PatternMinuteNode? minute) + { + if (_pattern[_index] != '{') + { + minute = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "minute.integer")) + { + _index += "{minute.integer".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + minute = new PatternMinuteNode(idealLength); + return true; + } + + minute = null; + return false; + } + + private bool TryParseSecond([NotNullWhen(true)] out PatternSecondNode? second) + { + if (_pattern[_index] != '{') + { + second = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "second.integer")) + { + _index += "{second.integer".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + second = new PatternSecondNode(idealLength); + return true; + } + + second = null; + return false; + } + + private bool TryParseTimeZone([NotNullWhen(true)] out PatternTimeZoneNode? timeZone) + { + if (_pattern[_index] != '{') + { + timeZone = null; + return false; + } + + if (IsWordAfterOpenBrace(_index, "timezone.full")) + { + _index += "{timezone.full".Length; + ExpectCharacterAndAdvance('}'); + + timeZone = new PatternTimeZoneNode(PatternTimeZoneNode.TimeZoneKind.Full); + return true; + } + else if (IsWordAfterOpenBrace(_index, "timezone.abbreviated")) + { + _index += "{timezone.abbreviated".Length; + int? idealLength = TryGetIdealLengthAndAdvance(); + ExpectCharacterAndAdvance('}'); + + timeZone = new PatternTimeZoneNode(PatternTimeZoneNode.TimeZoneKind.Full, idealLength); + return true; + } + + timeZone = null; + return false; + } + + private void ExpectCharacterAndAdvance(char c) + { + if (_index >= _pattern.Length || + _pattern[_index] != c) + { + throw new ArgumentException($"Expected character '{c}' at index {_index} of pattern '{_pattern}'."); + } + + _index++; + } + + private int? TryGetIdealLengthAndAdvance() + { + if (_pattern[_index] != '(' || + _index + 2 >= _pattern.Length || + _pattern[_index + 2] != ')') + { + return null; + } + + char idealLengthChar = _pattern[_index + 1]; + _index += 3; + if (idealLengthChar >= '1' && idealLengthChar <= '9') + { + return idealLengthChar - '0'; + } + + throw new ArgumentException($"The character '{idealLengthChar}' is not a valid ideal-length digit"); + } + + private PatternLiteralTextNode? ParseLiteralText() + { + // Keep looking for "{openbrace}", "{closebrace}", or any character that is not "{" or "}". + StringBuilder builder = new(); + int i = _index; + while (i < _pattern.Length) + { + if (_pattern[i] == '{') + { + if (IsOpenBraceLiteral(i)) + { + builder.Append('{'); + i += "{openbrace}".Length; + continue; + } + else if (IsCloseBraceLiteral(i)) + { + builder.Append('}'); + i += "{closebrace}".Length; + continue; + } + else + { + // NOT a literal. + break; + } + } + else if (_pattern[i] != '}') + { + // Not '{' and not '}', so part of the literal text. + builder.Append(_pattern[i]); + i++; + } + } + + _index = i; + return builder.Length == 0 ? null : new PatternLiteralTextNode(builder.ToString()); + } + + private bool IsWordAfterOpenBrace(int i, string word) + { + // i should be pointing at the index of '{'. + // We want to return true if we are at "{word". + // Note that we don't check for the "}" here + // This is because some callsites will continue parsing something between word and "}". + Debug.Assert(_pattern[i] == '{'); + + int wordLength = word.Length; + if (i + wordLength >= _pattern.Length) + { + return false; + } + + return _pattern.AsSpan().Slice(i + 1, wordLength).SequenceEqual(word); + } + + private bool IsOpenBraceLiteral(int i) + { + int openBraceLength = "openbrace".Length; + return IsWordAfterOpenBrace(i, "openbrace") && + i + openBraceLength + 1 < _pattern.Length && + _pattern[i + openBraceLength + 1] == '}'; + } + + private bool IsCloseBraceLiteral(int i) + { + int closeBraceLength = "closebrace".Length; + return IsWordAfterOpenBrace(i, "closebrace") && + i + closeBraceLength + 1 < _pattern.Length && + _pattern[i + closeBraceLength + 1] == '}'; + } +} diff --git a/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeTemplateParser/TemplateNodes.cs b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeTemplateParser/TemplateNodes.cs new file mode 100644 index 000000000000..2a020273afb5 --- /dev/null +++ b/src/Uno.UWP/Globalization/DateTimeFormatting/DateTimeTemplateParser/TemplateNodes.cs @@ -0,0 +1,574 @@ +#nullable enable + +using System; +using System.Collections.Immutable; + +namespace Windows.Globalization.DateTimeFormatting; + +internal sealed class DateTimeTemplateInfo +{ + public YearFormat IncludeYear { get; set; } + + public MonthFormat IncludeMonth { get; set; } + + public DayFormat IncludeDay { get; set; } + + public DayOfWeekFormat IncludeDayOfWeek { get; set; } + + public HourFormat IncludeHour { get; set; } + + public MinuteFormat IncludeMinute { get; set; } + + public SecondFormat IncludeSecond { get; set; } + + public TimeZoneFormat IncludeTimeZone { get; set; } + + public bool IsLongTime { get; set; } + + public bool IsShortTime { get; set; } + + public bool IsLongDate { get; set; } + + public bool IsShortDate { get; set; } +} + +internal abstract class TemplateNode +{ + internal abstract void Traverse(DateTimeTemplateInfo state); +} + +//