File: src\Shared\DateFormatStringsHelpers.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.RegularExpressions;
 
namespace Aspire.Dashboard.Utils;
 
internal static partial class DateFormatStringsHelpers
{
    // There are an unbound number of CultureInfo instances so we don't want to use it as the key.
    // Someone could have also customized their culture so we don't want to use the name as the key.
    // This struct contains required information from the culture that is used in cached format strings.
    private readonly record struct CultureDetailsKey(string LongTimePattern, string ShortDatePattern, string NumberDecimalSeparator, TimeFormat timeFormat);
    private sealed record MillisecondFormatStrings(CachedTimeFormatStrings LongTimePattern, CachedTimeFormatStrings ShortDateLongTimePattern);
    private sealed record CachedTimeFormatStrings(string None, string TruncatedMilliseconds, string FullMilliseconds);
 
    // Cache of format strings for each culture and 12h/24h override combination. Each entry is lazily initialized on demand.
    private static readonly ConcurrentDictionary<CultureDetailsKey, MillisecondFormatStrings> s_formatStrings = new();
 
    // Colon and dot are the only time separators used by registered cultures. Regex checks for both.
    [GeneratedRegex(@"(:ss|\.ss|:s|\.s)")]
    private static partial Regex MatchSecondsInTimeFormatPattern();
 
    // Matches 24-hour format specifiers: HH or H (but not h).
    [GeneratedRegex(@"HH?")]
    private static partial Regex MatchHourIn24HourTimeFormatPattern();
 
    // Matches 12-hour format specifiers: hh or h (but not H).
    [GeneratedRegex(@"hh?")]
    private static partial Regex MatchHourIn12HourTimeFormatPattern();
 
    // Matches AM/PM designator with optional leading whitespace.
    [GeneratedRegex(@"\s*tt")]
    private static partial Regex MatchAmPmDesignator();
 
    private static MillisecondFormatStrings GetDateFormatStrings(CultureInfo cultureInfo, TimeFormat timeFormat)
    {
        var key = new CultureDetailsKey(cultureInfo.DateTimeFormat.LongTimePattern, cultureInfo.DateTimeFormat.ShortDatePattern, cultureInfo.NumberFormat.NumberDecimalSeparator, timeFormat);
 
        return s_formatStrings.GetOrAdd(key, static k =>
        {
            var (none, truncated, full) = GetTimePatterns(k);
            return new MillisecondFormatStrings(
                new CachedTimeFormatStrings(none, truncated, full),
                new CachedTimeFormatStrings(
                    k.ShortDatePattern + " " + none,
                    k.ShortDatePattern + " " + truncated,
                    k.ShortDatePattern + " " + full));
        });
 
        static (string None, string Truncated, string Full) GetTimePatterns(CultureDetailsKey key)
        {
            // From https://learn.microsoft.com/dotnet/standard/base-types/how-to-display-milliseconds-in-date-and-time-values
 
            // Intentionally use fff here instead of FFF so output has a consistent length.
            return (
                FormatPattern(key, millisecondFormat: null),
                FormatPattern(key, "fff"),
                FormatPattern(key, "FFFFFFF"));
        }
 
        static string FormatPattern(CultureDetailsKey key, string? millisecondFormat)
        {
            // Gets the long time pattern, which is something like "h:mm:ss tt" (en-US), "H:mm:ss" (ja-JP), "HH:mm:ss" (fr-FR).
            var longTimePattern = key.LongTimePattern;
 
            // Apply 12h/24h override if requested.
            longTimePattern = key.timeFormat switch
            {
                TimeFormat.TwelveHour => ConvertTo12Hour(longTimePattern),
                TimeFormat.TwentyFourHour => ConvertTo24Hour(longTimePattern),
                _ => longTimePattern // System: use culture's pattern as-is
            };
 
            if (millisecondFormat is null)
            {
                return longTimePattern;
            }
 
            return MatchSecondsInTimeFormatPattern().Replace(longTimePattern, $"$1'{key.NumberDecimalSeparator}'{millisecondFormat}");
        }
 
        static string ConvertTo12Hour(string pattern)
        {
            // Replace H/HH with h (24h -> 12h).
            pattern = MatchHourIn24HourTimeFormatPattern().Replace(pattern, "h");
 
            // Add AM/PM designator if not already present.
            if (!pattern.Contains("tt"))
            {
                pattern += " tt";
            }
 
            return pattern;
        }
 
        static string ConvertTo24Hour(string pattern)
        {
            // Replace h/hh with H (12h -> 24h).
            pattern = MatchHourIn12HourTimeFormatPattern().Replace(pattern, "H");
 
            // Remove AM/PM designator.
            pattern = MatchAmPmDesignator().Replace(pattern, "").TrimEnd();
 
            return pattern;
        }
    }
 
    internal static string GetLongTimePattern(CultureInfo cultureInfo, TimeFormat timeFormat, MillisecondsDisplay millisecondsDisplay)
        => GetPattern(GetDateFormatStrings(cultureInfo, timeFormat).LongTimePattern, millisecondsDisplay);
 
    internal static string GetShortDateLongTimePattern(CultureInfo cultureInfo, TimeFormat timeFormat, MillisecondsDisplay millisecondsDisplay)
        => GetPattern(GetDateFormatStrings(cultureInfo, timeFormat).ShortDateLongTimePattern, millisecondsDisplay);
 
    private static string GetPattern(CachedTimeFormatStrings patterns, MillisecondsDisplay millisecondsDisplay)
    {
        return millisecondsDisplay switch
        {
            MillisecondsDisplay.None => patterns.None,
            MillisecondsDisplay.Truncated => patterns.TruncatedMilliseconds,
            MillisecondsDisplay.Full => patterns.FullMilliseconds,
            _ => throw new ArgumentException($"Unexpected {nameof(MillisecondsDisplay)} value: {millisecondsDisplay}.", nameof(millisecondsDisplay))
        };
    }
}