diff --git a/Jint.Tests/Runtime/EngineTests.cs b/Jint.Tests/Runtime/EngineTests.cs index f56c5cd7da..eaa0589a5a 100644 --- a/Jint.Tests/Runtime/EngineTests.cs +++ b/Jint.Tests/Runtime/EngineTests.cs @@ -1850,9 +1850,10 @@ public void DateToStringMethodsShouldUseCurrentTimeZoneAndCulture() equal('Mon Jun 01 2015 05:00:00 GMT-0700 (Pacific Standard Time)', d.toString()); equal('Mon Jun 01 2015', d.toDateString()); equal('05:00:00 GMT-0700 (Pacific Standard Time)', d.toTimeString()); - equal('lundi 1 juin 2015 05:00:00', d.toLocaleString()); - equal('lundi 1 juin 2015', d.toLocaleDateString()); - equal('05:00:00', d.toLocaleTimeString()); + // ECMA-402 compliant: numeric defaults used when no options specified + equal('1/6/2015 5:00:00', d.toLocaleString()); + equal('1/6/2015', d.toLocaleDateString()); + equal('5:00:00', d.toLocaleTimeString()); "); } diff --git a/Jint/Native/Array/ArrayPrototype.cs b/Jint/Native/Array/ArrayPrototype.cs index 21c8372301..a53600f2b6 100644 --- a/Jint/Native/Array/ArrayPrototype.cs +++ b/Jint/Native/Array/ArrayPrototype.cs @@ -1330,6 +1330,11 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) return JsString.Empty; } + // Per ECMA-402, always pass locales and options to element's toLocaleString + var locales = arguments.At(0); + var options = arguments.At(1); + var invokeArgs = new[] { locales, options }; + using var r = new ValueStringBuilder(); for (uint k = 0; k < len; k++) { @@ -1339,7 +1344,7 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) } if (array.TryGetValue(k, out var nextElement) && !nextElement.IsNullOrUndefined()) { - var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", [])); + var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", invokeArgs)); r.Append(s); } } diff --git a/Jint/Native/Date/DatePrototype.cs b/Jint/Native/Date/DatePrototype.cs index 1b62c1b40c..10cd969f9d 100644 --- a/Jint/Native/Date/DatePrototype.cs +++ b/Jint/Native/Date/DatePrototype.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Jint.Native.Intl; using Jint.Native.Object; using Jint.Native.Symbol; using Jint.Runtime; @@ -211,6 +212,7 @@ private JsValue ToTimeString(JsValue thisObject, JsCallArguments arguments) /// /// https://tc39.es/ecma262/#sec-date.prototype.tolocalestring + /// https://tc39.es/ecma402/#sup-date.prototype.tolocalestring /// private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { @@ -221,11 +223,37 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) return "Invalid Date"; } - return ToLocalTime(dateInstance).ToString("F", Engine.Options.Culture); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per ECMA-402 ToDateTimeOptions("any", "all"): + // If no date/time options are specified, use default numeric components + var optionsObj = IntlUtilities.CoerceOptionsToObject(Engine, options); + var needDefaults = NeedDateTimeDefaults(optionsObj, checkDate: true, checkTime: true); + + if (needDefaults) + { + // Add default date and time components per spec + // Use null prototype per ToDateTimeOptions step 2: ObjectCreate(options) + var newOptions = ObjectInstance.OrdinaryObjectCreate(Engine, null); + CopyOptions(optionsObj, newOptions); + newOptions.Set("year", "numeric"); + newOptions.Set("month", "numeric"); + newOptions.Set("day", "numeric"); + newOptions.Set("hour", "numeric"); + newOptions.Set("minute", "numeric"); + newOptions.Set("second", "numeric"); + options = newOptions; + } + + // Use Intl.DateTimeFormat for locale-aware formatting + var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat); + return dateTimeFormat.Format(ToLocalTime(dateInstance)); } /// /// https://tc39.es/ecma262/#sec-date.prototype.tolocaledatestring + /// https://tc39.es/ecma402/#sup-date.prototype.tolocaledatestring /// private JsValue ToLocaleDateString(JsValue thisObject, JsCallArguments arguments) { @@ -236,11 +264,34 @@ private JsValue ToLocaleDateString(JsValue thisObject, JsCallArguments arguments return "Invalid Date"; } - return ToLocalTime(dateInstance).ToString("D", Engine.Options.Culture); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per ECMA-402 ToDateTimeOptions("date", "date"): + // If no date options are specified, use default numeric date components + var optionsObj = IntlUtilities.CoerceOptionsToObject(Engine, options); + var needDefaults = NeedDateTimeDefaults(optionsObj, checkDate: true, checkTime: false); + + if (needDefaults) + { + // Add default date components per spec + // Use null prototype per ToDateTimeOptions step 2: ObjectCreate(options) + var newOptions = ObjectInstance.OrdinaryObjectCreate(Engine, null); + CopyOptions(optionsObj, newOptions); + newOptions.Set("year", "numeric"); + newOptions.Set("month", "numeric"); + newOptions.Set("day", "numeric"); + options = newOptions; + } + + // Use Intl.DateTimeFormat for locale-aware formatting + var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat); + return dateTimeFormat.Format(ToLocalTime(dateInstance)); } /// /// https://tc39.es/ecma262/#sec-date.prototype.tolocaletimestring + /// https://tc39.es/ecma402/#sup-date.prototype.tolocaletimestring /// private JsValue ToLocaleTimeString(JsValue thisObject, JsCallArguments arguments) { @@ -251,7 +302,96 @@ private JsValue ToLocaleTimeString(JsValue thisObject, JsCallArguments arguments return "Invalid Date"; } - return ToLocalTime(dateInstance).ToString("T", Engine.Options.Culture); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per ECMA-402 ToDateTimeOptions("time", "time"): + // If no time options are specified, use default numeric time components + var optionsObj = IntlUtilities.CoerceOptionsToObject(Engine, options); + var needDefaults = NeedDateTimeDefaults(optionsObj, checkDate: false, checkTime: true); + + if (needDefaults) + { + // Add default time components per spec + // Use null prototype per ToDateTimeOptions step 2: ObjectCreate(options) + var newOptions = ObjectInstance.OrdinaryObjectCreate(Engine, null); + CopyOptions(optionsObj, newOptions); + newOptions.Set("hour", "numeric"); + newOptions.Set("minute", "numeric"); + newOptions.Set("second", "numeric"); + options = newOptions; + } + + // Use Intl.DateTimeFormat for locale-aware formatting + var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat); + return dateTimeFormat.Format(ToLocalTime(dateInstance)); + } + + /// + /// Checks if default date/time options should be applied per ECMA-402 ToDateTimeOptions. + /// Returns true if no relevant options are specified and dateStyle/timeStyle are not present. + /// + private static bool NeedDateTimeDefaults(ObjectInstance options, bool checkDate, bool checkTime) + { + // If dateStyle or timeStyle is present, don't add defaults + if (!options.Get("dateStyle").IsUndefined() || !options.Get("timeStyle").IsUndefined()) + { + return false; + } + + // Check date-related properties + if (checkDate) + { + if (!options.Get("weekday").IsUndefined() || + !options.Get("year").IsUndefined() || + !options.Get("month").IsUndefined() || + !options.Get("day").IsUndefined() || + !options.Get("era").IsUndefined()) + { + return false; + } + } + + // Check time-related properties + if (checkTime) + { + if (!options.Get("dayPeriod").IsUndefined() || + !options.Get("hour").IsUndefined() || + !options.Get("minute").IsUndefined() || + !options.Get("second").IsUndefined() || + !options.Get("fractionalSecondDigits").IsUndefined()) + { + return false; + } + } + + return true; + } + + /// + /// Copies all defined options from source to target, preserving user-specified values. + /// + private static void CopyOptions(ObjectInstance source, ObjectInstance target) + { + // Copy all options that should be preserved (general + date/time components) + var optionsToCopy = new[] + { + // General options + "localeMatcher", "formatMatcher", "calendar", "numberingSystem", "timeZone", "hourCycle", "hour12", + "dateStyle", "timeStyle", + // Date components + "weekday", "era", "year", "month", "day", + // Time components + "dayPeriod", "hour", "minute", "second", "fractionalSecondDigits" + }; + foreach (var option in optionsToCopy) + { + var value = source.Get(option); + if (!value.IsUndefined()) + { + target.Set(option, value); + } + } } private JsValue GetTime(JsValue thisObject, JsCallArguments arguments) diff --git a/Jint/Native/Intl/ChineseCalendarHelper.cs b/Jint/Native/Intl/ChineseCalendarHelper.cs new file mode 100644 index 0000000000..fc8e6b07a5 --- /dev/null +++ b/Jint/Native/Intl/ChineseCalendarHelper.cs @@ -0,0 +1,192 @@ +using System.Globalization; + +namespace Jint.Native.Intl; + +/// +/// Helper class for Chinese and Dangi (Korean) calendar operations. +/// Provides conversion from Gregorian dates to Chinese/Dangi calendar dates +/// and computes the sexagenary cycle (干支 Gānzhī) year names. +/// +internal static class ChineseCalendarHelper +{ + // Lazy initialization to avoid startup overhead when Chinese calendar is not used + private static ChineseLunisolarCalendar? _chineseCalendar; + private static KoreanLunisolarCalendar? _koreanCalendar; + + private static ChineseLunisolarCalendar ChineseCalendar => + _chineseCalendar ??= new ChineseLunisolarCalendar(); + + private static KoreanLunisolarCalendar KoreanCalendar => + _koreanCalendar ??= new KoreanLunisolarCalendar(); + + /// + /// The 10 Heavenly Stems (天干 Tiāngān) in Chinese characters. + /// Used as part of the 60-year sexagenary cycle. + /// + private static readonly string[] HeavenlyStems = + [ + "甲", // jiǎ + "乙", // yǐ + "丙", // bǐng + "丁", // dīng + "戊", // wù + "己", // jǐ + "庚", // gēng + "辛", // xīn + "壬", // rén + "癸" // guǐ + ]; + + /// + /// The 12 Earthly Branches (地支 Dìzhī) in Chinese characters. + /// Used as part of the 60-year sexagenary cycle. + /// + private static readonly string[] EarthlyBranches = + [ + "子", // zǐ (Rat) + "丑", // chǒu (Ox) + "寅", // yín (Tiger) + "卯", // mǎo (Rabbit) + "辰", // chén (Dragon) + "巳", // sì (Snake) + "午", // wǔ (Horse) + "未", // wèi (Goat) + "申", // shēn (Monkey) + "酉", // yǒu (Rooster) + "戌", // xū (Dog) + "亥" // hài (Pig) + ]; + + /// + /// Gets Chinese calendar date information for a given DateTime. + /// + /// The Gregorian date to convert. + /// Chinese calendar date information including related year, year name, month, and day. + public static ChineseCalendarDate GetChineseDate(DateTime dateTime) + { + return GetLunisolarDate(dateTime, ChineseCalendar, isDangi: false); + } + + /// + /// Gets Dangi (Korean lunisolar) calendar date information for a given DateTime. + /// The Dangi calendar is essentially the same as the Chinese calendar. + /// + /// The Gregorian date to convert. + /// Dangi calendar date information including related year, year name, month, and day. + public static ChineseCalendarDate GetDangiDate(DateTime dateTime) + { + return GetLunisolarDate(dateTime, KoreanCalendar, isDangi: true); + } + + private static ChineseCalendarDate GetLunisolarDate(DateTime dateTime, EastAsianLunisolarCalendar calendar, bool isDangi) + { + // Clamp to supported range + var minDate = calendar.MinSupportedDateTime; + var maxDate = calendar.MaxSupportedDateTime; + + if (dateTime < minDate) + { + dateTime = minDate; + } + else if (dateTime > maxDate) + { + dateTime = maxDate; + } + + var year = calendar.GetYear(dateTime); + var month = calendar.GetMonth(dateTime); + var day = calendar.GetDayOfMonth(dateTime); + + // Check if this is a leap month + var leapMonth = calendar.GetLeapMonth(year); + var isLeapMonth = leapMonth > 0 && month == leapMonth; + + // Adjust month number for display (leap month has same number as previous month) + var displayMonth = month; + if (leapMonth > 0 && month >= leapMonth) + { + // If we're in or past the leap month, adjust the month number + displayMonth = month - 1; + if (month == leapMonth) + { + isLeapMonth = true; + } + } + + // The year from both ChineseLunisolarCalendar and KoreanLunisolarCalendar + // is already the Gregorian-aligned "related year" (the Gregorian year that mostly + // contains this lunar year). No offset adjustment is needed for either calendar. + var relatedYear = year; + + // Get the sexagenary cycle year name (干支) + var sexagenaryYear = calendar.GetSexagenaryYear(dateTime); + var yearName = GetSexagenaryYearName(sexagenaryYear); + + return new ChineseCalendarDate(relatedYear, yearName, displayMonth, day, isLeapMonth); + } + + /// + /// Gets the sexagenary cycle year name (干支 Gānzhī) for a given sexagenary year number. + /// + /// The sexagenary year number (1-60). + /// The two-character Chinese year name. + private static string GetSexagenaryYearName(int sexagenaryYear) + { + // Sexagenary year is 1-60, need to convert to 0-59 for array indexing + var index = sexagenaryYear - 1; + + // The sexagenary cycle combines 10 Heavenly Stems with 12 Earthly Branches + var stemIndex = index % 10; + var branchIndex = index % 12; + + return HeavenlyStems[stemIndex] + EarthlyBranches[branchIndex]; + } + + /// + /// Represents a date in the Chinese or Dangi lunisolar calendar. + /// + internal readonly struct ChineseCalendarDate + { + /// + /// Creates a new Chinese calendar date. + /// + public ChineseCalendarDate(int relatedYear, string yearName, int month, int day, bool isLeapMonth) + { + RelatedYear = relatedYear; + YearName = yearName; + Month = month; + Day = day; + IsLeapMonth = isLeapMonth; + } + + /// + /// The "related year" - the Gregorian year that mostly overlaps with this Chinese calendar year. + /// For dates before Chinese New Year, this is the previous Gregorian year. + /// + public int RelatedYear { get; } + + /// + /// The Chinese sexagenary cycle name (干支 Gānzhī) for the year. + /// Example: "己亥" (jǐ hài) for the year 2019. + /// + public string YearName { get; } + + /// + /// The month number in the Chinese calendar (1-12). + /// Note: Leap months are indicated separately by IsLeapMonth. + /// + public int Month { get; } + + /// + /// The day of the month in the Chinese calendar. + /// + public int Day { get; } + + /// + /// Whether this date falls in a leap month. + /// In lunisolar calendars, a leap month is an intercalary month + /// inserted to keep the calendar aligned with the solar year. + /// + public bool IsLeapMonth { get; } + } +} diff --git a/Jint/Native/Intl/DateTimeFormatConstructor.cs b/Jint/Native/Intl/DateTimeFormatConstructor.cs index cc7f2c7915..0e9cfd64d9 100644 --- a/Jint/Native/Intl/DateTimeFormatConstructor.cs +++ b/Jint/Native/Intl/DateTimeFormatConstructor.cs @@ -1,7 +1,9 @@ +using System.Globalization; using Jint.Native.Function; using Jint.Native.Object; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; namespace Jint.Native.Intl; @@ -11,6 +13,29 @@ namespace Jint.Native.Intl; internal sealed class DateTimeFormatConstructor : Constructor { private static readonly JsString _functionName = new("DateTimeFormat"); + private static readonly HashSet LocaleMatcherValues = ["lookup", "best fit"]; + private static readonly HashSet FormatMatcherValues = ["basic", "best fit"]; + private static readonly HashSet WeekdayValues = ["long", "short", "narrow"]; + private static readonly HashSet EraValues = ["long", "short", "narrow"]; + private static readonly HashSet YearValues = ["numeric", "2-digit"]; + private static readonly HashSet MonthValues = ["numeric", "2-digit", "long", "short", "narrow"]; + private static readonly HashSet DayValues = ["numeric", "2-digit"]; + private static readonly HashSet DayPeriodValues = ["long", "short", "narrow"]; + private static readonly HashSet HourValues = ["numeric", "2-digit"]; + private static readonly HashSet MinuteValues = ["numeric", "2-digit"]; + private static readonly HashSet SecondValues = ["numeric", "2-digit"]; + private static readonly HashSet TimeZoneNameValues = ["long", "short", "shortOffset", "longOffset", "shortGeneric", "longGeneric"]; + private static readonly HashSet HourCycleValues = ["h11", "h12", "h23", "h24"]; + private static readonly HashSet DateStyleValues = ["full", "long", "medium", "short"]; + private static readonly HashSet TimeStyleValues = ["full", "long", "medium", "short"]; + + // Supported calendars per ECMA-402 and Unicode CLDR + private static readonly HashSet SupportedCalendars = + [ + "buddhist", "chinese", "coptic", "dangi", "ethioaa", "ethiopic", + "gregory", "hebrew", "indian", "islamic", "islamic-civil", "islamic-rgsa", + "islamic-tbla", "islamic-umalqura", "iso8601", "japanese", "persian", "roc" + ]; public DateTimeFormatConstructor( Engine engine, @@ -24,32 +49,720 @@ public DateTimeFormatConstructor( _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); } + protected override void Initialize() + { + const PropertyFlag PropertyFlags = PropertyFlag.Configurable | PropertyFlag.Writable; + var properties = new PropertyDictionary(1, checkExistingKeys: false) + { + ["supportedLocalesOf"] = new(new ClrFunction(Engine, "supportedLocalesOf", SupportedLocalesOf, 1, PropertyFlag.Configurable), PropertyFlags) + }; + SetProperties(properties); + } + public DateTimeFormatPrototype PrototypeObject { get; } + /// + /// Called when Intl.DateTimeFormat is invoked without `new`. + /// + protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments) + { + return Construct(arguments, this); + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat + /// public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) { var locales = arguments.At(0); var options = arguments.At(1); - if (newTarget.IsUndefined()) + // Get options object + var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options); + + // Step 5: Get localeMatcher option first + var localeMatcher = GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, "best fit") ?? "best fit"; + + // Resolve locale (uses pre-read localeMatcher) + var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales); + var availableLocales = IntlUtilities.GetAvailableLocales(); + var resolved = IntlUtilities.ResolveLocale(_engine, availableLocales, requestedLocales, localeMatcher, []); + var resolvedLocale = resolved.Locale; + + // Extract unicode extension values from the requested locale + var requestedLocale = requestedLocales.Count > 0 ? requestedLocales[0] : ""; + var extCalendar = ExtractUnicodeExtensionFromLocale(requestedLocale, "ca"); + var extHourCycle = ExtractUnicodeExtensionFromLocale(requestedLocale, "hc"); + var extNumberingSystem = ExtractUnicodeExtensionFromLocale(requestedLocale, "nu"); + + // Track which extensions should be preserved in the resolved locale + var preserveCalendarExt = false; + var preserveHourCycleExt = false; + var preserveNumberingSystemExt = false; + + // Step 7: Get calendar option (between localeMatcher and hour12) + var calendarOption = GetUnicodeExtensionOption(optionsObj, "calendar"); + string? calendar; + + if (calendarOption != null) + { + // Option provided - validate and canonicalize + var canonicalizedOption = CanonicalizeCalendar(calendarOption); + if (IsValidCalendar(canonicalizedOption)) + { + // Valid option - it overrides extension + calendar = canonicalizedOption; + // If option matches extension, preserve the extension + preserveCalendarExt = extCalendar != null && string.Equals(calendar, CanonicalizeCalendar(extCalendar), StringComparison.Ordinal); + } + else if (extCalendar != null && IsValidCalendar(CanonicalizeCalendar(extCalendar))) + { + // Invalid option, but valid extension - use extension + calendar = CanonicalizeCalendar(extCalendar); + preserveCalendarExt = true; + } + else + { + // Both invalid - use default + calendar = null; + } + } + else if (extCalendar != null) + { + // No option, check if extension is valid + var canonicalizedExt = CanonicalizeCalendar(extCalendar); + if (IsValidCalendar(canonicalizedExt)) + { + calendar = canonicalizedExt; + preserveCalendarExt = true; + } + else + { + calendar = null; + } + } + else + { + calendar = null; + } + + // Step 10: Get numberingSystem option + var numberingSystemOption = optionsObj.Get("numberingSystem"); + string? numberingSystem; + var supported = _engine.Options.Intl.CldrProvider.GetSupportedNumberingSystems(); + + if (!numberingSystemOption.IsUndefined()) + { + var nsOptionStr = TypeConverter.ToString(numberingSystemOption); + if (!IntlUtilities.IsValidUnicodeExtensionValue(nsOptionStr)) + { + Throw.RangeError(_realm, $"Invalid value '{nsOptionStr}' for option 'numberingSystem'"); + } + // Validate against supported numbering systems + string? validatedOption = null; + foreach (var ns in supported) + { + if (string.Equals(ns, nsOptionStr, StringComparison.OrdinalIgnoreCase)) + { + validatedOption = ns; + break; + } + } + + if (validatedOption != null) + { + // Valid option - it overrides extension + numberingSystem = validatedOption; + // If option matches extension, preserve the extension + preserveNumberingSystemExt = extNumberingSystem != null && + string.Equals(numberingSystem, extNumberingSystem, StringComparison.OrdinalIgnoreCase); + } + else if (extNumberingSystem != null) + { + // Invalid option, check if extension is valid + string? validatedExt = null; + foreach (var ns in supported) + { + if (string.Equals(ns, extNumberingSystem, StringComparison.OrdinalIgnoreCase)) + { + validatedExt = ns; + break; + } + } + if (validatedExt != null) + { + numberingSystem = validatedExt; + preserveNumberingSystemExt = true; + } + else + { + // Both invalid - use default + numberingSystem = null; + } + } + else + { + // Invalid option and no extension - use default + numberingSystem = null; + } + } + else if (extNumberingSystem != null) + { + // No option, use extension value if supported + numberingSystem = null; + foreach (var ns in supported) + { + if (string.Equals(ns, extNumberingSystem, StringComparison.OrdinalIgnoreCase)) + { + numberingSystem = ns; + preserveNumberingSystemExt = true; + break; + } + } + } + else + { + numberingSystem = null; + } + + // Step 13: Get hour12 option + var hour12Value = optionsObj.Get("hour12"); + bool? hour12 = null; + if (!hour12Value.IsUndefined()) + { + hour12 = TypeConverter.ToBoolean(hour12Value); + } + + // Step 14: Get hourCycle option + var hourCycleOption = GetStringOption(optionsObj, "hourCycle", HourCycleValues, null); + string? hourCycle; + + if (hourCycleOption != null || hour12.HasValue) + { + // Option or hour12 provided - they override extension + if (hour12.HasValue) + { + if (hour12.Value) + { + var lang = resolvedLocale.Split('-')[0].ToLowerInvariant(); + hourCycle = string.Equals(lang, "ja", StringComparison.Ordinal) ? "h11" : "h12"; + } + else + { + hourCycle = "h23"; + } + } + else + { + hourCycle = hourCycleOption; + } + // If result matches extension, preserve the extension + preserveHourCycleExt = extHourCycle != null && string.Equals(hourCycle, extHourCycle, StringComparison.Ordinal); + } + else if (extHourCycle != null && HourCycleValues.Contains(extHourCycle)) + { + // No option, use extension value and preserve it + hourCycle = extHourCycle; + preserveHourCycleExt = true; + } + else + { + hourCycle = null; + } + + // Build the resolved locale with only preserved extensions + resolvedLocale = BuildResolvedLocale(resolvedLocale, requestedLocale, + preserveCalendarExt ? extCalendar : null, + preserveHourCycleExt ? extHourCycle : null, + preserveNumberingSystemExt ? extNumberingSystem : null); + + // Step 29: Get timeZone option + var timeZone = GetTimeZoneOption(optionsObj); + + // Step 36: Get component options in order (per Table 7 of ECMA-402) + // Order: weekday, era, year, month, day, dayPeriod, hour, minute, second, fractionalSecondDigits, timeZoneName + var weekday = GetStringOption(optionsObj, "weekday", WeekdayValues, null); + var era = GetStringOption(optionsObj, "era", EraValues, null); + var year = GetStringOption(optionsObj, "year", YearValues, null); + var month = GetStringOption(optionsObj, "month", MonthValues, null); + var day = GetStringOption(optionsObj, "day", DayValues, null); + var dayPeriod = GetStringOption(optionsObj, "dayPeriod", DayPeriodValues, null); + var hour = GetStringOption(optionsObj, "hour", HourValues, null); + var minute = GetStringOption(optionsObj, "minute", MinuteValues, null); + var second = GetStringOption(optionsObj, "second", SecondValues, null); + var fractionalSecondDigits = GetNumberOption(optionsObj, "fractionalSecondDigits", 1, 3, null); + var timeZoneName = GetStringOption(optionsObj, "timeZoneName", TimeZoneNameValues, null); + + // Step 37: Get formatMatcher option + GetStringOption(optionsObj, "formatMatcher", FormatMatcherValues, null); + + // Date/time style options + var dateStyle = GetStringOption(optionsObj, "dateStyle", DateStyleValues, null); + var timeStyle = GetStringOption(optionsObj, "timeStyle", TimeStyleValues, null); + + // If dateStyle or timeStyle is specified, component options must NOT be specified + if (dateStyle != null || timeStyle != null) + { + if (weekday != null || era != null || year != null || month != null || + day != null || dayPeriod != null || hour != null || minute != null || + second != null || fractionalSecondDigits != null || timeZoneName != null) + { + Throw.TypeError(_realm, "Can't set date/time component options when dateStyle or timeStyle is used"); + } + } + else + { + // If no date/time component options specified, use default date format + // Note: dayPeriod, fractionalSecondDigits, and timeZoneName don't prevent defaults + if (weekday == null && era == null && year == null && month == null && + day == null && dayPeriod == null && hour == null && minute == null && second == null) + { + year = "numeric"; + month = "numeric"; + day = "numeric"; + } + } + + // Get DateTimeFormatInfo for the locale + var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture; + var dateTimeFormatInfo = (DateTimeFormatInfo) culture.DateTimeFormat.Clone(); + + // Get prototype from newTarget (for cross-realm construction) + var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.DateTimeFormat.PrototypeObject); + + return new JsDateTimeFormat( + _engine, + proto, + resolvedLocale, + calendar, + numberingSystem, + timeZone, + hourCycle, + dateStyle, + timeStyle, + weekday, + era, + year, + month, + day, + dayPeriod, + hour, + minute, + second, + fractionalSecondDigits, + timeZoneName, + dateTimeFormatInfo, + culture); + } + + private string? GetStringOption(ObjectInstance options, string property, HashSet values, string? fallback) + { + var value = options.Get(property); + if (value.IsUndefined()) + { + return fallback; + } + + var stringValue = TypeConverter.ToString(value); + + if (values != null && values.Count > 0) + { + if (!values.Contains(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'"); + } + } + + return stringValue; + } + + private string? GetUnicodeExtensionOption(ObjectInstance options, string property) + { + var value = options.Get(property); + if (value.IsUndefined()) + { + return null; + } + + var stringValue = TypeConverter.ToString(value); + + // Validate against pattern: (3*8alphanum) *("-" (3*8alphanum)) + if (!IntlUtilities.IsValidUnicodeExtensionValue(stringValue)) + { + Throw.RangeError(_realm, $"Invalid value '{stringValue}' for option '{property}'"); + } + + return stringValue; + } + + private string? GetNumberingSystemOption(ObjectInstance options, List requestedLocales) + { + var value = options.Get("numberingSystem"); + string? requestedNS = null; + + if (!value.IsUndefined()) + { + requestedNS = TypeConverter.ToString(value); + + // Validate against pattern + if (!IntlUtilities.IsValidUnicodeExtensionValue(requestedNS)) + { + Throw.RangeError(_realm, $"Invalid value '{requestedNS}' for option 'numberingSystem'"); + } + } + else if (requestedLocales.Count > 0) + { + // Check for unicode extension -u-nu- + requestedNS = ExtractUnicodeExtensionFromLocale(requestedLocales[0], "nu"); + } + + // Validate against supported numbering systems + // Only return numbering systems we actually support (have digit mappings for) + if (requestedNS != null) + { + var supported = _engine.Options.Intl.CldrProvider.GetSupportedNumberingSystems(); + foreach (var ns in supported) + { + if (string.Equals(ns, requestedNS, StringComparison.OrdinalIgnoreCase)) + { + return ns; // Return canonical form + } + } + // Unsupported numbering system - fall back to null (will use locale default) + return null; + } + + return null; + } + + private int? GetNumberOption(ObjectInstance options, string property, int minimum, int maximum, int? fallback) + { + var value = options.Get(property); + if (value.IsUndefined()) { - newTarget = this; + return fallback; } - var dateTimeFormat = OrdinaryCreateFromConstructor( - newTarget, - static intrinsics => intrinsics.DateTimeFormat.PrototypeObject, - static (engine, _, _) => new JsObject(engine)); + var number = TypeConverter.ToNumber(value); - InitializeDateTimeFormat(dateTimeFormat, locales, options); - return dateTimeFormat; + // Per spec: check NaN and bounds BEFORE flooring + if (double.IsNaN(number) || number < minimum || number > maximum) + { + Throw.RangeError(_realm, $"Invalid value for option '{property}'"); + } + + // Return floor(value) + return (int) System.Math.Floor(number); + } + + private string? GetTimeZoneOption(ObjectInstance options) + { + var value = options.Get("timeZone"); + if (value.IsUndefined()) + { + return null; + } + + var timeZone = TypeConverter.ToString(value); + + // Validate and canonicalize time zone using IANA database + // Per ECMA-402, only IANA timezone identifiers are allowed + if (string.Equals(timeZone, "UTC", StringComparison.OrdinalIgnoreCase)) + { + return "UTC"; + } + + // Check for offset time zones like "+03", "-07:30", "+05:45" + var canonicalOffset = TryParseOffsetTimeZone(timeZone); + if (canonicalOffset != null) + { + return canonicalOffset; + } + + // Look up in our IANA timezone database (case-insensitive) + // This is the only source of valid timezone names per ECMA-402 + var canonicalId = Data.TimeZoneData.FindCanonical(timeZone); + if (canonicalId != null) + { + return canonicalId; + } + + // Not a valid IANA timezone identifier + Throw.RangeError(_realm, $"Invalid time zone: {timeZone}"); + return null; + } + + /// + /// Parses and canonicalizes an offset time zone string. + /// Valid formats: "+HH", "-HH", "+HH:MM", "-HH:MM", "+HHMM", "-HHMM" + /// Returns canonical form like "+03:00" or "-07:30", or null if not a valid offset. + /// + private static string? TryParseOffsetTimeZone(string timeZone) + { + if (string.IsNullOrEmpty(timeZone) || timeZone.Length < 3) + { + return null; + } + + var sign = timeZone[0]; + if (sign != '+' && sign != '-') + { + return null; + } + + var len = timeZone.Length; + int hours; + int minutes = 0; + + // Parse based on format with strict position validation + if (len == 3) + { + // "+HH" or "-HH" format - positions 1,2 must be digits + if (!char.IsDigit(timeZone[1]) || !char.IsDigit(timeZone[2])) + { + return null; + } + hours = (timeZone[1] - '0') * 10 + (timeZone[2] - '0'); + } + else if (len == 5) + { + // "+HHMM" format - positions 1,2,3,4 must all be digits + if (!char.IsDigit(timeZone[1]) || !char.IsDigit(timeZone[2]) || + !char.IsDigit(timeZone[3]) || !char.IsDigit(timeZone[4])) + { + return null; + } + hours = (timeZone[1] - '0') * 10 + (timeZone[2] - '0'); + minutes = (timeZone[3] - '0') * 10 + (timeZone[4] - '0'); + } + else if (len == 6 && timeZone[3] == ':') + { + // "+HH:MM" format - positions 1,2 and 4,5 must be digits, position 3 is ':' + if (!char.IsDigit(timeZone[1]) || !char.IsDigit(timeZone[2]) || + !char.IsDigit(timeZone[4]) || !char.IsDigit(timeZone[5])) + { + return null; + } + hours = (timeZone[1] - '0') * 10 + (timeZone[2] - '0'); + minutes = (timeZone[4] - '0') * 10 + (timeZone[5] - '0'); + } + else + { + return null; + } + + // Validate ranges: hours 0-23, minutes 0-59 + // Note: ECMA-402 allows offsets up to ±23:59 + if (hours > 23 || minutes > 59) + { + return null; + } + + // Return canonical form: ±HH:MM + // Per ECMA-402, -00:00 is normalized to +00:00 + if (hours == 0 && minutes == 0) + { + return "+00:00"; + } + + return $"{sign}{hours:D2}:{minutes:D2}"; + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat.supportedlocalesof + /// + private JsArray SupportedLocalesOf(JsValue thisObject, JsCallArguments arguments) + { + var locales = arguments.At(0); + var options = arguments.At(1); + + var requestedLocales = IntlUtilities.CanonicalizeLocaleList(_engine, locales); + var availableLocales = IntlUtilities.GetAvailableLocales(); + + // Validate localeMatcher option + var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options); + GetStringOption(optionsObj, "localeMatcher", LocaleMatcherValues, fallback: null); + + List supported = []; + foreach (var locale in requestedLocales) + { + var bestAvailable = IntlUtilities.BestAvailableLocale(availableLocales, locale); + if (bestAvailable != null) + { + supported.Add(locale); + } + } + + return new JsArray(_engine, supported.ToArray()); } /// - /// https://tc39.es/ecma402/#sec-initializedatetimeformat + /// Checks if the given calendar is supported. /// - private static void InitializeDateTimeFormat(JsObject dateTimeFormat, JsValue locales, JsValue options) + private static bool IsValidCalendar(string? calendar) { - // TODO + return calendar != null && SupportedCalendars.Contains(calendar); + } + + /// + /// Builds the resolved locale string, including only the unicode extensions that should be preserved. + /// + private static string BuildResolvedLocale(string baseLocale, string requestedLocale, + string? calendar, string? hourCycle, string? numberingSystem) + { + // If no extensions to preserve, return base locale (without any extensions) + if (calendar == null && hourCycle == null && numberingSystem == null) + { + return baseLocale; + } + + // Build the unicode extension string + var extensions = new List(); + + // Extensions are added in alphabetical order by key + if (calendar != null) + { + extensions.Add($"ca-{calendar}"); + } + if (hourCycle != null) + { + extensions.Add($"hc-{hourCycle}"); + } + if (numberingSystem != null) + { + extensions.Add($"nu-{numberingSystem}"); + } + + if (extensions.Count == 0) + { + return baseLocale; + } + + return $"{baseLocale}-u-{string.Join("-", extensions)}"; + } + + /// + /// Canonicalizes calendar names per ECMA-402. + /// Converts deprecated/alias names to their canonical forms. + /// Per ECMA-402, "islamic" and "islamic-rgsa" should fallback to a valid calendar + /// from AvailableCalendars (like "islamic-civil"). + /// + private static string? CanonicalizeCalendar(string? calendar) + { + if (calendar == null) + { + return null; + } + + // Calendar alias and fallback mappings per Unicode CLDR and ECMA-402 + return calendar.ToLowerInvariant() switch + { + "ethiopic-amete-alem" => "ethioaa", + "islamicc" => "islamic-civil", + // Per ECMA-402 §11.1.2, "islamic" and "islamic-rgsa" are deprecated + // and should fallback to a valid calendar from AvailableCalendars + "islamic" => "islamic-civil", + "islamic-rgsa" => "islamic-civil", + _ => calendar.ToLowerInvariant() + }; + } + + /// + /// Extracts a unicode extension value from the locale list. + /// For example, extracts "h11" from "de-u-hc-h11" when key is "hc". + /// + private static string? ExtractUnicodeExtensionValue(List locales, string key, HashSet? validValues) + { + foreach (var locale in locales) + { + var value = ExtractUnicodeExtensionFromLocale(locale, key); + if (value != null) + { + // Validate against allowed values if provided + if (validValues == null || validValues.Contains(value)) + { + return value; + } + } + } + return null; + } + + /// + /// Extracts a specific unicode extension value from a single locale string. + /// + private static string? ExtractUnicodeExtensionFromLocale(string locale, string key) + { + // Look for -u- marker + var uIndex = locale.IndexOf("-u-", StringComparison.OrdinalIgnoreCase); + if (uIndex == -1) + { + return null; + } + + // Parse the unicode extension looking for the key + var extensionStart = uIndex + 3; + var i = extensionStart; + + while (i < locale.Length) + { + // Find the key (2 characters) + var keyStart = i; + while (i < locale.Length && locale[i] != '-') + { + i++; + } + + var currentKey = locale.Substring(keyStart, i - keyStart); + + // Move past the '-' if present + if (i < locale.Length && locale[i] == '-') + { + i++; + } + + // Check if this is a 2-character key (not a singleton for next extension) + if (currentKey.Length == 2) + { + // Find the value(s) - collect until next key or end + var valueStart = i; + while (i < locale.Length) + { + var partStart = i; + while (i < locale.Length && locale[i] != '-') + { + i++; + } + + var part = locale.Substring(partStart, i - partStart); + + // If this part is a 2-char key or 1-char singleton, stop + if (part.Length == 2 || part.Length == 1) + { + break; + } + + // Move past '-' if present + if (i < locale.Length && locale[i] == '-') + { + i++; + } + } + + var value = locale.Substring(valueStart, i - valueStart).TrimEnd('-'); + + if (string.Equals(currentKey, key, StringComparison.OrdinalIgnoreCase) && value.Length > 0) + { + return value.ToLowerInvariant(); + } + } + else if (currentKey.Length == 1) + { + // This is a singleton starting a new extension type, stop parsing unicode extension + break; + } + } + + return null; } } diff --git a/Jint/Native/Intl/DateTimeFormatPrototype.cs b/Jint/Native/Intl/DateTimeFormatPrototype.cs index db5a75789e..50e4c01a58 100644 --- a/Jint/Native/Intl/DateTimeFormatPrototype.cs +++ b/Jint/Native/Intl/DateTimeFormatPrototype.cs @@ -1,7 +1,9 @@ +using Jint.Native.Date; using Jint.Native.Object; using Jint.Native.Symbol; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; namespace Jint.Native.Intl; @@ -23,16 +25,789 @@ public DateTimeFormatPrototype(Engine engine, protected override void Initialize() { - var properties = new PropertyDictionary(2, checkExistingKeys: false) + const PropertyFlag LengthFlags = PropertyFlag.Configurable; + const PropertyFlag PropertyFlags = PropertyFlag.Writable | PropertyFlag.Configurable; + + var properties = new PropertyDictionary(5, checkExistingKeys: false) { - ["constructor"] = new PropertyDescriptor(_constructor, true, false, true), + ["constructor"] = new PropertyDescriptor(_constructor, PropertyFlag.NonEnumerable), + ["resolvedOptions"] = new PropertyDescriptor(new ClrFunction(Engine, "resolvedOptions", ResolvedOptions, 0, LengthFlags), PropertyFlags), + ["formatToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatToParts", FormatToParts, 1, LengthFlags), PropertyFlags), + ["formatRange"] = new PropertyDescriptor(new ClrFunction(Engine, "formatRange", FormatRange, 2, LengthFlags), PropertyFlags), + ["formatRangeToParts"] = new PropertyDescriptor(new ClrFunction(Engine, "formatRangeToParts", FormatRangeToParts, 2, LengthFlags), PropertyFlags), }; SetProperties(properties); + // format is an accessor property - accessor properties don't have writable attribute + SetAccessor("format", new GetSetPropertyDescriptor( + new ClrFunction(Engine, "get format", GetFormat, 0, LengthFlags), + Undefined, + PropertyFlag.Configurable)); + var symbols = new SymbolDictionary(1) { [GlobalSymbolRegistry.ToStringTag] = new("Intl.DateTimeFormat", PropertyFlag.Configurable) }; SetSymbols(symbols); } + + private void SetAccessor(string name, GetSetPropertyDescriptor descriptor) + { + SetProperty(name, descriptor); + } + + private JsDateTimeFormat ValidateDateTimeFormat(JsValue thisObject) + { + if (thisObject is JsDateTimeFormat dateTimeFormat) + { + return dateTimeFormat; + } + + Throw.TypeError(_realm, "Value is not an Intl.DateTimeFormat"); + return null!; // Never reached + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.format + /// + private ClrFunction GetFormat(JsValue thisObject, JsCallArguments arguments) + { + var dateTimeFormat = ValidateDateTimeFormat(thisObject); + + // Return a bound format function + return new ClrFunction(Engine, "", (_, args) => + { + var dateValue = args.At(0); + var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear); + return dateTimeFormat.Format(dateTime, originalYear); + }, 1, PropertyFlag.Configurable); + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formattoparts + /// + private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments) + { + var dateTimeFormat = ValidateDateTimeFormat(thisObject); + var dateValue = arguments.At(0); + var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear); + + var parts = dateTimeFormat.FormatToParts(dateTime, originalYear); + var result = new JsArray(Engine, (uint) parts.Count); + + for (var i = 0; i < parts.Count; i++) + { + var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + partObj.Set("type", parts[i].Type); + partObj.Set("value", parts[i].Value); + result.SetIndexValue((uint) i, partObj, updateLength: true); + } + + return result; + } + + private DateTime ToDateTime(JsValue value) + { + if (value.IsUndefined()) + { + return DateTime.Now; + } + + if (value is JsDate jsDate) + { + // Check if date is within .NET DateTime range + if (!jsDate.DateTimeRangeValid) + { + // Date is outside .NET range - return min/max based on sign + return jsDate.DateValue < 0 ? DateTime.MinValue : DateTime.MaxValue; + } + + // ECMA-402 requires formatting in local time unless a specific timezone is provided + var dt = jsDate.ToDateTime(); + if (dt.Kind == DateTimeKind.Utc || dt.Kind == DateTimeKind.Unspecified) + { + dt = dt.ToLocalTime(); + } + return dt; + } + + var timeValue = TypeConverter.ToNumber(value); + DatePresentation presentation = timeValue; + presentation = presentation.TimeClip(); + + if (presentation.IsNaN) + { + Throw.RangeError(_realm, "Invalid time value"); + } + + // Clamp to .NET DateTime range if necessary + if (presentation.Value < JsDate.Min) + { + return DateTime.MinValue; + } + if (presentation.Value > JsDate.Max) + { + return DateTime.MaxValue; + } + + return presentation.ToDateTime().ToLocalTime(); + } + + /// + /// Converts a JavaScript value to DateTime, returning the original JavaScript year + /// when the date is outside .NET DateTime range (for proper era formatting). + /// + private DateTime ToDateTimeWithOriginalYear(JsValue value, out int? originalYear) + { + if (value.IsUndefined()) + { + originalYear = null; + return DateTime.Now; + } + + if (value is JsDate jsDate) + { + // Check if date is within .NET DateTime range + if (!jsDate.DateTimeRangeValid) + { + // Extract the original JavaScript year from the date value + originalYear = GetJavaScriptYear(jsDate.DateValue); + // Date is outside .NET range - return min/max based on sign + return jsDate.DateValue < 0 ? DateTime.MinValue : DateTime.MaxValue; + } + + // ECMA-402 requires formatting in local time unless a specific timezone is provided + var dateTime = jsDate.ToDateTime(); + if (dateTime.Kind == DateTimeKind.Utc || dateTime.Kind == DateTimeKind.Unspecified) + { + dateTime = dateTime.ToLocalTime(); + } + originalYear = null; + return dateTime; + } + + var timeValue = TypeConverter.ToNumber(value); + DatePresentation presentation = timeValue; + presentation = presentation.TimeClip(); + + if (presentation.IsNaN) + { + Throw.RangeError(_realm, "Invalid time value"); + } + + // Clamp to .NET DateTime range if necessary + if (presentation.Value < JsDate.Min) + { + originalYear = GetJavaScriptYear(presentation.Value); + return DateTime.MinValue; + } + + if (presentation.Value > JsDate.Max) + { + originalYear = GetJavaScriptYear(presentation.Value); + return DateTime.MaxValue; + } + + originalYear = null; + return presentation.ToDateTime().ToLocalTime(); + } + + /// + /// Extracts the JavaScript year from a timestamp value using the ECMAScript algorithm. + /// JavaScript Date uses milliseconds since Unix epoch (1970-01-01). + /// + private static int GetJavaScriptYear(double timeValue) + { + // ECMAScript YearFromTime algorithm + // https://tc39.es/ecma262/#sec-yearfromtime + const double msPerDay = 86400000; + + // Day number from epoch + var day = System.Math.Floor(timeValue / msPerDay); + + // Estimate year (rough approximation to start binary search) + var year = (int) (1970 + day / 365.2425); + + // Binary search refinement for exact year + while (DayFromYear(year + 1) <= day) + { + year++; + } + while (DayFromYear(year) > day) + { + year--; + } + + return year; + } + + /// + /// Returns the day number of the first day of a given year (ECMAScript algorithm). + /// + private static double DayFromYear(int year) + { + // https://tc39.es/ecma262/#sec-dayfromyear + return 365.0 * (year - 1970) + + System.Math.Floor((year - 1969) / 4.0) + - System.Math.Floor((year - 1901) / 100.0) + + System.Math.Floor((year - 1601) / 400.0); + } + + /// + /// Gets the default hour cycle for a locale. + /// Most locales use h12, but some use h23 (24-hour format without leading zero for midnight). + /// + private static string GetDefaultHourCycle(string locale) + { + // Most English-speaking locales use 12-hour format + var lang = locale.Split('-')[0].ToLowerInvariant(); + + // 24-hour format locales + if (lang is "de" or "fr" or "it" or "es" or "pt" or "nl" or "ru" or "pl" or "sv" or "da" or "nb" or "fi") + { + return "h23"; + } + + // Japanese uses h11 for 12-hour format (0-11 instead of 1-12) + if (string.Equals(lang, "ja", StringComparison.Ordinal)) + { + return "h11"; + } + + // Default to 12-hour format h12 (1-12) + return "h12"; + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.resolvedoptions + /// + private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments) + { + var dateTimeFormat = ValidateDateTimeFormat(thisObject); + + var result = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + + // Use CreateDataPropertyOrThrow to avoid prototype chain setters + result.CreateDataPropertyOrThrow("locale", dateTimeFormat.Locale); + + if (dateTimeFormat.Calendar != null) + { + result.CreateDataPropertyOrThrow("calendar", dateTimeFormat.Calendar); + } + else + { + result.CreateDataPropertyOrThrow("calendar", "gregory"); + } + + result.CreateDataPropertyOrThrow("numberingSystem", dateTimeFormat.NumberingSystem ?? "latn"); + + // timeZone is always present - use local timezone if not specified + // Convert Windows timezone IDs to IANA format per ECMA-402 + var timeZoneId = dateTimeFormat.TimeZone ?? TimeZoneInfo.Local.Id; + result.CreateDataPropertyOrThrow("timeZone", ToIanaTimeZoneId(timeZoneId)); + + // hourCycle and hour12 should be returned if hour is present OR if timeStyle is set + // Per ECMA-402, timeStyle implies hour formatting + if (dateTimeFormat.Hour != null || dateTimeFormat.TimeStyle != null) + { + // Use provided hourCycle or derive default from locale + var hourCycle = dateTimeFormat.HourCycle ?? GetDefaultHourCycle(dateTimeFormat.Locale); + result.CreateDataPropertyOrThrow("hourCycle", hourCycle); + result.CreateDataPropertyOrThrow("hour12", string.Equals(hourCycle, "h11", StringComparison.Ordinal) || + string.Equals(hourCycle, "h12", StringComparison.Ordinal)); + } + + if (dateTimeFormat.DateStyle != null) + { + result.CreateDataPropertyOrThrow("dateStyle", dateTimeFormat.DateStyle); + } + + if (dateTimeFormat.TimeStyle != null) + { + result.CreateDataPropertyOrThrow("timeStyle", dateTimeFormat.TimeStyle); + } + + // Component options + if (dateTimeFormat.Weekday != null) + { + result.CreateDataPropertyOrThrow("weekday", dateTimeFormat.Weekday); + } + + if (dateTimeFormat.Era != null) + { + result.CreateDataPropertyOrThrow("era", dateTimeFormat.Era); + } + + if (dateTimeFormat.Year != null) + { + result.CreateDataPropertyOrThrow("year", dateTimeFormat.Year); + } + + if (dateTimeFormat.Month != null) + { + result.CreateDataPropertyOrThrow("month", dateTimeFormat.Month); + } + + if (dateTimeFormat.Day != null) + { + result.CreateDataPropertyOrThrow("day", dateTimeFormat.Day); + } + + // dayPeriod comes after day and before hour per ECMA-402 spec order + if (dateTimeFormat.DayPeriod != null) + { + result.CreateDataPropertyOrThrow("dayPeriod", dateTimeFormat.DayPeriod); + } + + if (dateTimeFormat.Hour != null) + { + result.CreateDataPropertyOrThrow("hour", dateTimeFormat.Hour); + } + + if (dateTimeFormat.Minute != null) + { + result.CreateDataPropertyOrThrow("minute", dateTimeFormat.Minute); + } + + if (dateTimeFormat.Second != null) + { + result.CreateDataPropertyOrThrow("second", dateTimeFormat.Second); + } + + if (dateTimeFormat.FractionalSecondDigits.HasValue) + { + result.CreateDataPropertyOrThrow("fractionalSecondDigits", dateTimeFormat.FractionalSecondDigits.Value); + } + + if (dateTimeFormat.TimeZoneName != null) + { + result.CreateDataPropertyOrThrow("timeZoneName", dateTimeFormat.TimeZoneName); + } + + return result; + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formatrange + /// + private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments) + { + var dateTimeFormat = ValidateDateTimeFormat(thisObject); + + var startDate = arguments.At(0); + var endDate = arguments.At(1); + + // Validate arguments + if (startDate.IsUndefined() || endDate.IsUndefined()) + { + Throw.TypeError(_realm, "startDate and endDate are required"); + } + + var start = ToDateTimeForRange(startDate); + var end = ToDateTimeForRange(endDate); + + // Format both dates + var startFormatted = dateTimeFormat.Format(start); + var endFormatted = dateTimeFormat.Format(end); + + // If the dates are the same when formatted, return just one + if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal)) + { + return startFormatted; + } + + // Get parts for both dates to apply collapsing logic + var startParts = dateTimeFormat.FormatToParts(start); + var endParts = dateTimeFormat.FormatToParts(end); + + // Determine if we should use interval collapsing + var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts); + + if (useCollapsing) + { + // Build collapsed range string + var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts); + var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd); + + // Only collapse if there's a shared suffix + // If the year (last component) differs, we should output full dates + var hasSuffix = sharedSuffixStart < startParts.Count; + + if (hasSuffix) + { + var result = new System.Text.StringBuilder(); + + // Add shared prefix + for (var i = 0; i < sharedPrefixEnd; i++) + { + result.Append(startParts[i].Value); + } + + // Add start range differing parts + for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++) + { + result.Append(startParts[i].Value); + } + + // Add separator + result.Append(" – "); + + // Add end range differing parts + for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++) + { + result.Append(endParts[i].Value); + } + + // Add shared suffix + for (var i = sharedSuffixStart; i < startParts.Count; i++) + { + result.Append(startParts[i].Value); + } + + return result.ToString(); + } + } + + // Return a range string without collapsing + return $"{startFormatted} – {endFormatted}"; + } + + /// + /// https://tc39.es/ecma402/#sec-intl.datetimeformat.prototype.formatrangetoparts + /// + private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments) + { + var dateTimeFormat = ValidateDateTimeFormat(thisObject); + + var startDate = arguments.At(0); + var endDate = arguments.At(1); + + // Validate arguments + if (startDate.IsUndefined() || endDate.IsUndefined()) + { + Throw.TypeError(_realm, "startDate and endDate are required"); + } + + var start = ToDateTimeForRange(startDate); + var end = ToDateTimeForRange(endDate); + + // Get parts for both dates + var startParts = dateTimeFormat.FormatToParts(start); + var endParts = dateTimeFormat.FormatToParts(end); + + // Check if dates are practically equal (same formatted output) + var startFormatted = dateTimeFormat.Format(start); + var endFormatted = dateTimeFormat.Format(end); + + var result = new JsArray(Engine); + uint index = 0; + + if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal)) + { + // Dates are practically equal - return parts with source "shared" + foreach (var part in startParts) + { + var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + partObj.Set("type", part.Type); + partObj.Set("value", part.Value); + partObj.Set("source", "shared"); + result.SetIndexValue(index++, partObj, updateLength: true); + } + } + else + { + // Determine if we should use interval collapsing + // Collapsing is used when explicit component options are set (not dateStyle/timeStyle) + // and the month is textual (not numeric) + var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts); + + if (useCollapsing) + { + // Find shared prefix and suffix + var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts); + var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd); + + // Only collapse if there's a shared suffix + // If the year (last component) differs, we should output full dates + var hasSuffix = sharedSuffixStart < startParts.Count; + + if (hasSuffix) + { + // Add shared prefix + for (var i = 0; i < sharedPrefixEnd; i++) + { + AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared"); + } + + // Add start range differing parts + for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++) + { + AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "startRange"); + } + + // Add separator + AddPartToResult(result, ref index, "literal", " – ", "shared"); + + // Add end range differing parts + for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++) + { + AddPartToResult(result, ref index, endParts[i].Type, endParts[i].Value, "endRange"); + } + + // Add shared suffix + for (var i = sharedSuffixStart; i < startParts.Count; i++) + { + AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared"); + } + } + else + { + // No suffix to share - output full dates + foreach (var part in startParts) + { + AddPartToResult(result, ref index, part.Type, part.Value, "startRange"); + } + + AddPartToResult(result, ref index, "literal", " – ", "shared"); + + foreach (var part in endParts) + { + AddPartToResult(result, ref index, part.Type, part.Value, "endRange"); + } + } + } + else + { + // No collapsing - output full dates with separator + foreach (var part in startParts) + { + AddPartToResult(result, ref index, part.Type, part.Value, "startRange"); + } + + AddPartToResult(result, ref index, "literal", " – ", "shared"); + + foreach (var part in endParts) + { + AddPartToResult(result, ref index, part.Type, part.Value, "endRange"); + } + } + } + + return result; + } + + private void AddPartToResult(JsArray result, ref uint index, string type, string value, string source) + { + var partObj = OrdinaryObjectCreate(Engine, Engine.Realm.Intrinsics.Object.PrototypeObject); + partObj.Set("type", type); + partObj.Set("value", value); + partObj.Set("source", source); + result.SetIndexValue(index++, partObj, updateLength: true); + } + + private static bool ShouldUseIntervalCollapsing(JsDateTimeFormat format, List startParts, List endParts) + { + // Don't collapse if using dateStyle/timeStyle + if (format.DateStyle != null || format.TimeStyle != null) + { + return false; + } + + // Don't collapse if parts have different lengths + if (startParts.Count != endParts.Count) + { + return false; + } + + // Use collapsing when month is textual (short, long, narrow) + // and we have explicit component options + var hasTextualMonth = format.Month is "short" or "long" or "narrow"; + var hasExplicitComponents = format.Year != null || format.Month != null || format.Day != null; + + return hasTextualMonth && hasExplicitComponents; + } + + private static int FindSharedPrefixEnd(List startParts, List endParts) + { + var minLen = System.Math.Min(startParts.Count, endParts.Count); + var i = 0; + + while (i < minLen) + { + var startPart = startParts[i]; + var endPart = endParts[i]; + + // Parts match if type and value are the same + if (!string.Equals(startPart.Type, endPart.Type, StringComparison.Ordinal) || + !string.Equals(startPart.Value, endPart.Value, StringComparison.Ordinal)) + { + break; + } + + i++; + } + + // Include trailing literal in prefix if the next non-literal parts also match + // This handles cases like "Jan " where the space should be shared + while (i > 0 && i < minLen && + string.Equals(startParts[i - 1].Type, "literal", StringComparison.Ordinal)) + { + // Check if the previous non-literal parts match + var prevNonLiteral = i - 2; + while (prevNonLiteral >= 0 && string.Equals(startParts[prevNonLiteral].Type, "literal", StringComparison.Ordinal)) + { + prevNonLiteral--; + } + + if (prevNonLiteral >= 0 && + string.Equals(startParts[prevNonLiteral].Value, endParts[prevNonLiteral].Value, StringComparison.Ordinal)) + { + // Include the literal in the prefix + break; + } + else + { + // Don't include trailing literal that precedes different values + i--; + } + } + + return i; + } + + private static int FindSharedSuffixStart(List startParts, List endParts, int prefixEnd) + { + var startLen = startParts.Count; + var endLen = endParts.Count; + + if (startLen != endLen) + { + return startLen; + } + + var i = startLen - 1; + + while (i >= prefixEnd) + { + var startPart = startParts[i]; + var endPart = endParts[i]; + + // Parts match if type and value are the same + if (!string.Equals(startPart.Type, endPart.Type, StringComparison.Ordinal) || + !string.Equals(startPart.Value, endPart.Value, StringComparison.Ordinal)) + { + break; + } + + i--; + } + + // Move past the last differing part to get suffix start + return i + 1; + } + + private DateTime ToDateTimeForRange(JsValue value) + { + if (value is JsDate jsDate) + { + // Check if date is within .NET DateTime range + if (!jsDate.DateTimeRangeValid) + { + // Date is outside .NET range - return min/max based on sign + return jsDate.DateValue < 0 ? DateTime.MinValue : DateTime.MaxValue; + } + + var dt = jsDate.ToDateTime(); + if (dt == DateTime.MinValue) + { + // Invalid date + Throw.RangeError(_realm, "Invalid time value"); + } + // ECMA-402 requires formatting in local time unless a specific timezone is provided + if (dt.Kind == DateTimeKind.Utc || dt.Kind == DateTimeKind.Unspecified) + { + dt = dt.ToLocalTime(); + } + return dt; + } + + var timeValue = TypeConverter.ToNumber(value); + DatePresentation presentation = timeValue; + presentation = presentation.TimeClip(); + + if (presentation.IsNaN) + { + Throw.RangeError(_realm, "Invalid time value"); + } + + // Clamp to .NET DateTime range if necessary + if (presentation.Value < JsDate.Min) + { + return DateTime.MinValue; + } + if (presentation.Value > JsDate.Max) + { + return DateTime.MaxValue; + } + + return presentation.ToDateTime().ToLocalTime(); + } + + /// + /// Converts a timezone ID to IANA format. + /// Windows uses names like "FLE Standard Time" but ECMA-402 requires IANA names like "Europe/Helsinki". + /// + private static string ToIanaTimeZoneId(string timeZoneId) + { + // UTC is already valid + if (string.Equals(timeZoneId, "UTC", StringComparison.OrdinalIgnoreCase)) + { + return "UTC"; + } + + // Offset timezones are already valid (e.g., "+03:00") + if (timeZoneId.Length > 0 && (timeZoneId[0] == '+' || timeZoneId[0] == '-')) + { + return timeZoneId; + } + +#if NET6_0_OR_GREATER + // Try to convert Windows ID to IANA + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZoneId, out var ianaId)) + { + return ianaId; + } +#endif + + // Check if it's already an IANA ID (contains '/') + if (timeZoneId.Contains('/')) + { + return timeZoneId; + } + + // Fallback: try to get the IANA ID from the TimeZoneInfo + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); +#if NET6_0_OR_GREATER + // On .NET 6+, check if HasIanaId is available + if (tz.HasIanaId) + { + return tz.Id; + } + // Try conversion again with the found timezone + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(tz.Id, out ianaId)) + { + return ianaId; + } +#endif + } + catch + { + // Ignore errors + } + + // Last resort: return as-is (won't pass validation but better than crashing) + return timeZoneId; + } } diff --git a/Jint/Native/Intl/IntlInstance.cs b/Jint/Native/Intl/IntlInstance.cs index 668b25ea73..e3acade5e4 100644 --- a/Jint/Native/Intl/IntlInstance.cs +++ b/Jint/Native/Intl/IntlInstance.cs @@ -35,6 +35,7 @@ protected override void Initialize() var properties = new PropertyDictionary(4, checkExistingKeys: false) { ["Collator"] = new(_realm.Intrinsics.Collator, PropertyFlags), + ["DateTimeFormat"] = new(_realm.Intrinsics.DateTimeFormat, PropertyFlags), ["Locale"] = new(_realm.Intrinsics.Locale, PropertyFlags), ["NumberFormat"] = new(_realm.Intrinsics.NumberFormat, PropertyFlags), ["PluralRules"] = new(_realm.Intrinsics.PluralRules, PropertyFlags), diff --git a/Jint/Native/Intl/JsDateTimeFormat.cs b/Jint/Native/Intl/JsDateTimeFormat.cs new file mode 100644 index 0000000000..6244697a99 --- /dev/null +++ b/Jint/Native/Intl/JsDateTimeFormat.cs @@ -0,0 +1,1593 @@ +using System.Globalization; +using System.Text; +using Jint.Native.Object; + +namespace Jint.Native.Intl; + +/// +/// https://tc39.es/ecma402/#sec-datetimeformat-objects +/// Represents an Intl.DateTimeFormat instance with locale-aware date/time formatting. +/// +internal sealed class JsDateTimeFormat : ObjectInstance +{ + internal JsDateTimeFormat( + Engine engine, + ObjectInstance prototype, + string locale, + string? calendar, + string? numberingSystem, + string? timeZone, + string? hourCycle, + string? dateStyle, + string? timeStyle, + string? weekday, + string? era, + string? year, + string? month, + string? day, + string? dayPeriod, + string? hour, + string? minute, + string? second, + int? fractionalSecondDigits, + string? timeZoneName, + DateTimeFormatInfo dateTimeFormatInfo, + CultureInfo cultureInfo) : base(engine) + { + _prototype = prototype; + Locale = locale; + Calendar = calendar; + NumberingSystem = numberingSystem; + TimeZone = timeZone; + HourCycle = hourCycle; + DateStyle = dateStyle; + TimeStyle = timeStyle; + Weekday = weekday; + Era = era; + Year = year; + Month = month; + Day = day; + DayPeriod = dayPeriod; + Hour = hour; + Minute = minute; + Second = second; + FractionalSecondDigits = fractionalSecondDigits; + TimeZoneName = timeZoneName; + DateTimeFormatInfo = dateTimeFormatInfo; + CultureInfo = cultureInfo; + } + + internal string Locale { get; } + internal string? Calendar { get; } + internal string? NumberingSystem { get; } + internal string? TimeZone { get; } + internal string? HourCycle { get; } + internal string? DateStyle { get; } + internal string? TimeStyle { get; } + internal string? Weekday { get; } + internal string? Era { get; } + internal string? Year { get; } + internal string? Month { get; } + internal string? Day { get; } + internal string? DayPeriod { get; } + internal string? Hour { get; } + internal string? Minute { get; } + internal string? Second { get; } + internal int? FractionalSecondDigits { get; } + internal string? TimeZoneName { get; } + internal DateTimeFormatInfo DateTimeFormatInfo { get; } + internal CultureInfo CultureInfo { get; } + + /// + /// Gets the CLDR provider from engine options. + /// + private ICldrProvider CldrProvider => _engine.Options.Intl.CldrProvider; + + /// + /// Formats a date according to the formatter's locale and options. + /// + /// The .NET DateTime to format + /// Optional original JavaScript year (for dates outside .NET DateTime range) + internal string Format(DateTime dateTime, int? originalYear = null) + { + // For Chinese and Dangi calendars, use FormatToParts to get consistent output + // This ensures the special part types (relatedYear, yearName) are properly handled + var isLunisolarCalendar = string.Equals(Calendar, "chinese", StringComparison.OrdinalIgnoreCase) || + string.Equals(Calendar, "dangi", StringComparison.OrdinalIgnoreCase); + + // For era formatting, use FormatToParts to ensure proper year formatting for BC dates + // This is needed because .NET format strings don't handle proleptic Gregorian years correctly + var hasEra = Era != null; + + if (isLunisolarCalendar || hasEra) + { + var parts = FormatToParts(dateTime, originalYear); + var sb = new StringBuilder(); + foreach (var part in parts) + { + sb.Append(part.Value); + } + return sb.ToString(); + } + + // Convert to specified timezone if one was provided + if (TimeZone != null) + { + dateTime = ConvertToTimeZone(dateTime, TimeZone); + } + + string result; + + // If dateStyle or timeStyle is specified, use those + if (DateStyle != null || TimeStyle != null) + { + result = FormatWithStyles(dateTime); + } + else + { + // Otherwise build format from component options + result = FormatWithComponents(dateTime, originalYear); + } + + // Apply numbering system transliteration if not using Latin digits + if (NumberingSystem != null && !string.Equals(NumberingSystem, "latn", StringComparison.OrdinalIgnoreCase)) + { + result = Data.NumberingSystemData.TransliterateDigits(result, NumberingSystem); + } + + return result; + } + + private static DateTime ConvertToTimeZone(DateTime dateTime, string timeZoneId) + { + if (string.Equals(timeZoneId, "UTC", StringComparison.OrdinalIgnoreCase) || + string.Equals(timeZoneId, "+00:00", StringComparison.Ordinal)) + { + // Convert to UTC + if (dateTime.Kind == DateTimeKind.Local) + { + return dateTime.ToUniversalTime(); + } + return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + } + + // Check for offset timezone format like "+03:00", "-07:30" + var offset = TryParseOffset(timeZoneId); + if (offset.HasValue) + { + // Convert to UTC first + if (dateTime.Kind == DateTimeKind.Local) + { + dateTime = dateTime.ToUniversalTime(); + } + dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + + // Apply the offset + return dateTime.Add(offset.Value); + } + + try + { + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + if (dateTime.Kind == DateTimeKind.Local) + { + dateTime = dateTime.ToUniversalTime(); + } + return TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc), timeZone); + } + catch + { + // If timezone lookup fails, return as-is + return dateTime; + } + } + + /// + /// Parses an offset timezone string like "+03:00" or "-07:30" and returns the TimeSpan offset. + /// + private static TimeSpan? TryParseOffset(string timeZoneId) + { + if (string.IsNullOrEmpty(timeZoneId) || timeZoneId.Length != 6) + { + return null; + } + + var sign = timeZoneId[0]; + if (sign != '+' && sign != '-') + { + return null; + } + + if (timeZoneId[3] != ':') + { + return null; + } + + // Parse hours and minutes using direct character parsing for compatibility + if (!char.IsDigit(timeZoneId[1]) || !char.IsDigit(timeZoneId[2]) || + !char.IsDigit(timeZoneId[4]) || !char.IsDigit(timeZoneId[5])) + { + return null; + } + + var hours = (timeZoneId[1] - '0') * 10 + (timeZoneId[2] - '0'); + var minutes = (timeZoneId[4] - '0') * 10 + (timeZoneId[5] - '0'); + + var totalMinutes = hours * 60 + minutes; + if (sign == '-') + { + totalMinutes = -totalMinutes; + } + + return TimeSpan.FromMinutes(totalMinutes); + } + + /// + /// Gets the era name for a date based on the calendar and style. + /// Returns null for calendars that don't have eras (chinese, dangi). + /// + /// The .NET DateTime (may be clamped for dates outside .NET range) + /// The calendar type + /// The era style (long, short, narrow) + /// The original JavaScript year (for dates outside .NET DateTime range) + private string? GetEraName(DateTime dateTime, string calendar, string style, int? originalYear = null) + { + // Chinese and Dangi calendars don't use eras + if (string.Equals(calendar, "chinese", StringComparison.OrdinalIgnoreCase) || + string.Equals(calendar, "dangi", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Get era names from CLDR provider if available + var eraNames = CldrProvider.GetEraNames(Locale, style); + + // Use original year for era calculation if the date was clamped + var effectiveYear = originalYear ?? dateTime.Year; + + return calendar.ToLowerInvariant() switch + { + "gregory" or "iso8601" => GetGregorianEra(effectiveYear, style, eraNames), + "japanese" => GetJapaneseEra(dateTime, effectiveYear, style, eraNames), + "roc" => GetRocEra(effectiveYear, style, eraNames), + "buddhist" => GetBuddhistEra(style, eraNames), + "hebrew" => GetHebrewEra(style, eraNames), + "persian" => GetPersianEra(style, eraNames), + "indian" => GetIndianEra(style, eraNames), + "ethioaa" or "ethiopic" => GetEthiopicEra(style, eraNames), + "coptic" => GetCopticEra(effectiveYear, style, eraNames), + "islamic" or "islamic-civil" or "islamic-tbla" or "islamic-umalqura" => GetIslamicEra(style, eraNames), + _ => GetGregorianEra(effectiveYear, style, eraNames) // Default to Gregorian + }; + } + + private static string GetGregorianEra(int year, string style, string[]? eraNames) + { + var isAD = year > 0; + if (eraNames != null && eraNames.Length >= 2) + { + return isAD ? eraNames[1] : eraNames[0]; + } + // Fallback era names + return style switch + { + "long" => isAD ? "Anno Domini" : "Before Christ", + "short" => isAD ? "AD" : "BC", + "narrow" => isAD ? "A" : "B", + _ => isAD ? "AD" : "BC" + }; + } + + private static string GetJapaneseEra(DateTime dateTime, int effectiveYear, string style, string[]? eraNames) + { + // Japanese era calculation + // Reiwa: 2019-05-01 onwards + // Heisei: 1989-01-08 to 2019-04-30 + // Showa: 1926-12-25 to 1989-01-07 + // Taisho: 1912-07-30 to 1926-12-24 + // Meiji: 1868-01-25 to 1912-07-29 + // Before Meiji + + // Determine era index and get name based on style + // For Japanese eras, short and long use the same full name + var isNarrow = string.Equals(style, "narrow", StringComparison.Ordinal); + + // Check for dates within .NET DateTime range using actual DateTime comparison + if (effectiveYear >= 2019 && dateTime >= new DateTime(2019, 5, 1)) + { + return isNarrow ? "R" : "Reiwa"; + } + + if (effectiveYear >= 1989 && dateTime >= new DateTime(1989, 1, 8)) + { + return isNarrow ? "H" : "Heisei"; + } + + if (effectiveYear >= 1926 && dateTime >= new DateTime(1926, 12, 25)) + { + return isNarrow ? "S" : "Shōwa"; + } + + if (effectiveYear >= 1912 && dateTime >= new DateTime(1912, 7, 30)) + { + return isNarrow ? "T" : "Taishō"; + } + + if (effectiveYear >= 1868 && dateTime >= new DateTime(1868, 1, 25)) + { + return isNarrow ? "M" : "Meiji"; + } + + // Before Meiji - use Gregorian era based on the effective year + return effectiveYear > 0 ? "AD" : "BC"; + } + + private static string GetRocEra(int year, string style, string[]? eraNames) + { + // Republic of China calendar: year 1 = 1912 CE + // Note: eraNames from CLDR are Gregorian, not ROC-specific, so we use hardcoded values + var isAfter1912 = year >= 1912; + return style switch + { + "long" => isAfter1912 ? "Minguo" : "Before R.O.C.", + "short" => isAfter1912 ? "Minguo" : "Before R.O.C.", + "narrow" => isAfter1912 ? "R.O.C." : "B.R.O.C.", + _ => isAfter1912 ? "Minguo" : "Before R.O.C." + }; + } + + private static string GetBuddhistEra(string style, string[]? eraNames) + { + // Buddhist calendar has single era (BE - Buddhist Era) + // Note: eraNames from CLDR are Gregorian, not Buddhist-specific + return style switch + { + "long" => "Buddhist Era", + "short" => "BE", + "narrow" => "BE", + _ => "BE" + }; + } + + private static string GetHebrewEra(string style, string[]? eraNames) + { + // Hebrew calendar has single era (AM - Anno Mundi) + // Note: eraNames from CLDR are Gregorian, not Hebrew-specific + return style switch + { + "long" => "Anno Mundi", + "short" => "AM", + "narrow" => "AM", + _ => "AM" + }; + } + + private static string GetPersianEra(string style, string[]? eraNames) + { + // Persian calendar has single era (AP - Anno Persico) + // Note: eraNames from CLDR are Gregorian, not Persian-specific + return style switch + { + "long" => "Anno Persico", + "short" => "AP", + "narrow" => "AP", + _ => "AP" + }; + } + + private static string GetIndianEra(string style, string[]? eraNames) + { + // Indian national calendar has single era (Saka) + // Note: eraNames from CLDR are Gregorian, not Indian-specific + return style switch + { + "long" => "Saka", + "short" => "Saka", + "narrow" => "Saka", + _ => "Saka" + }; + } + + private static string GetEthiopicEra(string style, string[]? eraNames) + { + // Ethiopic calendar uses Era of the Incarnation + // Note: eraNames from CLDR are Gregorian, not Ethiopic-specific + return style switch + { + "long" => "Era of the Incarnation", + "short" => "ERA1", + "narrow" => "ERA1", + _ => "ERA1" + }; + } + + private static string GetCopticEra(int year, string style, string[]? eraNames) + { + // Coptic calendar has two eras + // Note: eraNames from CLDR are Gregorian, not Coptic-specific, so we use hardcoded values + var isAfterEpoch = year >= 284; // Year 284 CE + return style switch + { + "long" => isAfterEpoch ? "Era of the Martyrs" : "Before Era of the Martyrs", + "short" => isAfterEpoch ? "AM" : "BAM", + "narrow" => isAfterEpoch ? "AM" : "BAM", + _ => isAfterEpoch ? "AM" : "BAM" + }; + } + + private static string GetIslamicEra(string style, string[]? eraNames) + { + // Islamic calendar has single era (AH - Anno Hegirae) + // Note: eraNames from CLDR are Gregorian, not Islamic-specific + return style switch + { + "long" => "Anno Hegirae", + "short" => "AH", + "narrow" => "AH", + _ => "AH" + }; + } + + /// + /// Holds locale-specific date format information. + /// + private readonly struct LocaleDateFormatInfo + { + public LocaleDateFormatInfo(string dateOrder, string dateSeparator, bool hasTextualMonth) + { + DateOrder = dateOrder; + DateSeparator = dateSeparator; + HasTextualMonth = hasTextualMonth; + } + + /// Date component order as "Mdy", "dMy", or "yMd". + public string DateOrder { get; } + /// Separator between date components. + public string DateSeparator { get; } + /// Whether the month is textual (long, short, narrow) vs numeric. + public bool HasTextualMonth { get; } + } + + /// + /// Determines the locale-specific date format order and separator. + /// + private LocaleDateFormatInfo GetLocaleDateFormat() + { + // Check if month is textual (long, short, narrow) vs numeric + var hasTextualMonth = Month != null && Month is "long" or "short" or "narrow"; + + // Get locale's short date pattern to determine order + var lang = Locale.Split('-')[0].ToLowerInvariant(); + var region = Locale.Contains('-') ? Locale.Split('-')[1].ToUpperInvariant() : ""; + + // Determine date component order based on locale + // MDY: en-US, en-CA (Canada uses MDY in English), fil (Philippines) + // DMY: Most of Europe, UK, Australia, most of world + // YMD: East Asia (China, Japan, Korea), Lithuania, Hungary + string dateOrder; + if (string.Equals(lang, "en", StringComparison.Ordinal) && + (string.Equals(region, "US", StringComparison.Ordinal) || + string.Equals(region, "", StringComparison.Ordinal))) + { + // en-US and plain "en" use MDY + dateOrder = "Mdy"; + } + else if (string.Equals(lang, "zh", StringComparison.Ordinal) || + string.Equals(lang, "ja", StringComparison.Ordinal) || + string.Equals(lang, "ko", StringComparison.Ordinal) || + string.Equals(lang, "hu", StringComparison.Ordinal) || + string.Equals(lang, "lt", StringComparison.Ordinal)) + { + // East Asian languages and Hungarian/Lithuanian use YMD + dateOrder = "yMd"; + } + else + { + // Most of the world uses DMY + dateOrder = "dMy"; + } + + // Determine separator based on whether month is textual + string dateSeparator; + if (hasTextualMonth) + { + // Textual month uses space separators: "Jan 3, 2019" + dateSeparator = " "; + } + else + { + // Numeric format uses "/" for en-US, varies by locale + dateSeparator = "/"; + } + + return new LocaleDateFormatInfo(dateOrder, dateSeparator, hasTextualMonth); + } + + private void AddMonthPart(DateTime dateTime, List result, ref bool hasDate, string separator, bool hasTextualMonth, ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null) + { + if (result.Count > 0 && hasDate) + { + result.Add(new DateTimePart("literal", separator)); + } + + string monthValue; + if (lunisolarDate.HasValue) + { + // Use Chinese/Dangi calendar month + var chineseMonth = lunisolarDate.Value.Month; + monthValue = Month switch + { + "numeric" => chineseMonth.ToString(CultureInfo), + "2-digit" => chineseMonth.ToString("D2", CultureInfo), + // For textual months in lunisolar calendars, we still use numeric + // as Chinese month names are not typically used in Intl formatting + "long" or "short" or "narrow" => chineseMonth.ToString(CultureInfo), + _ => chineseMonth.ToString("D2", CultureInfo) + }; + } + else + { + var format = Month switch + { + "numeric" => "%M", + "2-digit" => "MM", + "long" => "MMMM", + "short" => "MMM", + "narrow" => "MMM", + _ => "MM" + }; + monthValue = dateTime.ToString(format, CultureInfo); + } + + result.Add(new DateTimePart("month", monthValue)); + hasDate = true; + } + + private void AddDayPart(DateTime dateTime, List result, ref bool hasDate, string separator, bool hasTextualMonth, ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null) + { + if (result.Count > 0 && hasDate) + { + result.Add(new DateTimePart("literal", separator)); + } + + string dayValue; + if (lunisolarDate.HasValue) + { + // Use Chinese/Dangi calendar day + var chineseDay = lunisolarDate.Value.Day; + dayValue = Day switch + { + "numeric" => chineseDay.ToString(CultureInfo), + "2-digit" => chineseDay.ToString("D2", CultureInfo), + _ => chineseDay.ToString("D2", CultureInfo) + }; + } + else + { + var format = Day switch + { + "numeric" => "%d", + "2-digit" => "dd", + _ => "dd" + }; + dayValue = dateTime.ToString(format, CultureInfo); + } + + result.Add(new DateTimePart("day", dayValue)); + hasDate = true; + } + + private void AddYearPart(DateTime dateTime, List result, ref bool hasDate, string separator, bool hasTextualMonth, ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null, int? originalYear = null) + { + if (result.Count > 0 && hasDate) + { + // For textual month format, use ", " before year if it comes last + var actualSeparator = hasTextualMonth ? ", " : separator; + result.Add(new DateTimePart("literal", actualSeparator)); + } + + if (lunisolarDate.HasValue) + { + // For Chinese/Dangi calendars, output relatedYear and yearName instead of year + var relatedYear = lunisolarDate.Value.RelatedYear; + var yearName = lunisolarDate.Value.YearName; + + // Check locale for formatting - zh locale uses "年" suffix + var lang = Locale.Split('-')[0].ToLowerInvariant(); + var isChineseLocale = string.Equals(lang, "zh", StringComparison.Ordinal); + + // Add relatedYear part + var relatedYearValue = Year switch + { + "numeric" => relatedYear.ToString(CultureInfo), + "2-digit" => (relatedYear % 100).ToString("00", CultureInfo), + _ => relatedYear.ToString(CultureInfo) + }; + result.Add(new DateTimePart("relatedYear", relatedYearValue)); + + // Add yearName part (干支 sexagenary cycle name) + result.Add(new DateTimePart("yearName", yearName)); + + // For Chinese locale, add "年" (year) suffix + if (isChineseLocale) + { + result.Add(new DateTimePart("literal", "年")); + } + } + else + { + // Use original year if provided (for dates outside .NET DateTime range) + var effectiveYear = originalYear ?? dateTime.Year; + + // For proleptic Gregorian calendar with era, convert negative years to positive BC years + // Year 0 in astronomical notation = 1 BC, year -1 = 2 BC, etc. + var displayYear = Era != null && effectiveYear <= 0 ? 1 - effectiveYear : System.Math.Abs(effectiveYear); + + var yearValue = Year switch + { + // For numeric with era, use plain number without leading zeros + "numeric" => displayYear.ToString(CultureInfo), + "2-digit" => (displayYear % 100).ToString("00", CultureInfo), + _ => displayYear.ToString(CultureInfo) + }; + result.Add(new DateTimePart("year", yearValue)); + } + hasDate = true; + } + + private string FormatWithStyles(DateTime dateTime) + { + // When both dateStyle and timeStyle are specified, combine them appropriately + if (DateStyle != null && TimeStyle != null) + { + // Format date and time separately and combine with ", " + var datePart = FormatDateStyleOnly(dateTime); + var timePart = FormatTimeStyle(dateTime); + return $"{datePart}, {timePart}"; + } + + if (DateStyle != null) + { + return FormatDateStyleOnly(dateTime); + } + + if (TimeStyle != null) + { + return FormatTimeStyle(dateTime); + } + + return dateTime.ToString("G", CultureInfo); + } + + private string FormatDateStyleOnly(DateTime dateTime) + { + return DateStyle switch + { + "full" => dateTime.ToString("D", CultureInfo), // Full date pattern (includes weekday) + "long" => FormatLongDate(dateTime), // Long date without weekday + "medium" => FormatMediumDate(dateTime), // Medium date (same as long for most locales) + "short" => FormatShortDate(dateTime), // Short date (numeric) + _ => dateTime.ToString("d", CultureInfo) + }; + } + + /// + /// Formats a date in long style (without weekday), e.g., "May 1, 1886" + /// + private string FormatLongDate(DateTime dateTime) + { + // Use MMMM d, yyyy for en-US style, or locale-appropriate pattern + var lang = Locale.Split('-')[0].ToLowerInvariant(); + if (string.Equals(lang, "en", StringComparison.Ordinal)) + { + return dateTime.ToString("MMMM d, yyyy", CultureInfo); + } + // For other locales, use the long date pattern without weekday + var longPattern = CultureInfo.DateTimeFormat.LongDatePattern; + // Remove weekday-related format specifiers manually + var modifiedPattern = RemoveWeekdayFromPattern(longPattern); + if (string.IsNullOrEmpty(modifiedPattern)) + { + return dateTime.ToString("MMMM d, yyyy", CultureInfo); + } + return dateTime.ToString(modifiedPattern, CultureInfo); + } + + private static string RemoveWeekdayFromPattern(string pattern) + { + // Remove dddd or ddd followed by optional comma/space + var result = pattern; + var weekdayPatterns = new[] { "dddd, ", "dddd,", "dddd ", "dddd", "ddd, ", "ddd,", "ddd ", "ddd" }; + foreach (var wp in weekdayPatterns) + { + var idx = result.IndexOf(wp, StringComparison.Ordinal); + if (idx >= 0) + { + result = result.Remove(idx, wp.Length); + break; + } + } + return result.Trim().TrimStart(',').Trim(); + } + + /// + /// Formats a date in medium style, e.g., "May 1, 1886" + /// + private string FormatMediumDate(DateTime dateTime) + { + // Medium is typically the same as long for most locales + return FormatLongDate(dateTime); + } + + /// + /// Formats a date in short style, e.g., "5/1/86" + /// + private string FormatShortDate(DateTime dateTime) + { + // Use locale's short date pattern but with 2-digit year + var lang = Locale.Split('-')[0].ToLowerInvariant(); + if (string.Equals(lang, "en", StringComparison.Ordinal)) + { + // US style: M/d/yy with literal slash separator + return dateTime.ToString("M'/'d'/'yy", CultureInfo); + } + // For other locales, use the short date pattern + return dateTime.ToString("d", CultureInfo); + } + + /// + /// Formats time using timeStyle, respecting hourCycle + /// + private string FormatTimeStyle(DateTime dateTime) + { + // Determine if we should use 12 or 24 hour format + bool use12Hour; + if (HourCycle != null) + { + // Explicit hourCycle takes precedence + use12Hour = string.Equals(HourCycle, "h11", StringComparison.Ordinal) || + string.Equals(HourCycle, "h12", StringComparison.Ordinal); + } + else + { + // Derive from locale - English uses 12-hour, most others use 24-hour + var lang = Locale.Split('-')[0].ToLowerInvariant(); + use12Hour = string.Equals(lang, "en", StringComparison.Ordinal); + } + + var timeZoneSuffix = ""; + if (string.Equals(TimeStyle, "full", StringComparison.Ordinal)) + { + if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase)) + { + timeZoneSuffix = " Coordinated Universal Time"; + } + else + { + timeZoneSuffix = " " + TimeZoneInfo.Local.DisplayName; + } + } + else if (string.Equals(TimeStyle, "long", StringComparison.Ordinal)) + { + if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase)) + { + timeZoneSuffix = " UTC"; + } + } + + return TimeStyle switch + { + "full" => use12Hour + ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + timeZoneSuffix + : dateTime.ToString("HH:mm:ss", CultureInfo) + timeZoneSuffix, + "long" => use12Hour + ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + timeZoneSuffix + : dateTime.ToString("HH:mm:ss", CultureInfo) + timeZoneSuffix, + "medium" => use12Hour + ? dateTime.ToString("h:mm:ss", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + : dateTime.ToString("HH:mm:ss", CultureInfo), + "short" => use12Hour + ? dateTime.ToString("h:mm", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + : dateTime.ToString("HH:mm", CultureInfo), + _ => use12Hour + ? dateTime.ToString("h:mm", CultureInfo) + " " + GetDayPeriod(dateTime.Hour) + : dateTime.ToString("HH:mm", CultureInfo), + }; + } + + private static string GetDayPeriod(int hour) + { + return hour < 12 ? "AM" : "PM"; + } + + private string FormatWithComponents(DateTime dateTime, int? originalYear = null) + { + // Build a custom format string based on component options + var parts = new List(); + string? eraValue = null; + + // Get locale-specific date format info + var formatInfo = GetLocaleDateFormat(); + + // Weekday + if (Weekday != null) + { + parts.Add(Weekday switch + { + "long" => "dddd", + "short" => "ddd", + "narrow" => "ddd", + _ => "ddd" + }); + } + + // Era - get the era name but add it after formatting (since .NET doesn't support custom eras) + if (Era != null) + { + eraValue = GetEraName(dateTime, Calendar ?? "gregory", Era, originalYear); + } + + // Add date parts in locale-specific order + foreach (var component in formatInfo.DateOrder) + { + switch (component) + { + case 'M' when Month != null: + parts.Add(Month switch + { + "numeric" => "M", + "2-digit" => "MM", + "long" => "MMMM", + "short" => "MMM", + "narrow" => "MMM", + _ => "MM" + }); + break; + case 'd' when Day != null: + parts.Add(Day switch + { + "numeric" => "d", + "2-digit" => "dd", + _ => "dd" + }); + break; + case 'y' when Year != null: + parts.Add(Year switch + { + "numeric" => "yyyy", + "2-digit" => "yy", + _ => "yyyy" + }); + break; + } + } + + // Hour + if (Hour != null) + { + var use12Hour = string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal); + parts.Add(Hour switch + { + "numeric" => use12Hour ? "h" : "H", + "2-digit" => use12Hour ? "hh" : "HH", + _ => use12Hour ? "h" : "H" + }); + } + + // Minute - for time components, "numeric" typically uses 2-digit padding in most locales + if (Minute != null) + { + parts.Add(Minute switch + { + "numeric" => "mm", + "2-digit" => "mm", + _ => "mm" + }); + } + + // Second - for time components, "numeric" typically uses 2-digit padding in most locales + if (Second != null) + { + parts.Add(Second switch + { + "numeric" => "ss", + "2-digit" => "ss", + _ => "ss" + }); + } + + // Fractional seconds + if (FractionalSecondDigits.HasValue && FractionalSecondDigits.Value > 0) + { + parts.Add(new string('f', FractionalSecondDigits.Value)); + } + + // Day period (AM/PM) - only add "tt" if using 12-hour format with hour specified + // and DayPeriod is not explicitly specified (DayPeriod uses extended periods) + var needsAmPm = Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal) && DayPeriod == null; + if (needsAmPm) + { + parts.Add("tt"); + } + + // Time zone name + if (TimeZoneName != null) + { + parts.Add(TimeZoneName switch + { + "long" => "zzz", + "short" => "zzz", + "shortOffset" => "zzz", + "longOffset" => "zzz", + "shortGeneric" => "zzz", + "longGeneric" => "zzz", + _ => "zzz" + }); + } + + // Handle DayPeriod option (extended day periods like "in the morning") + if (DayPeriod != null) + { + // If only dayPeriod is specified (no other components), just return the day period + if (parts.Count == 0 && eraValue == null) + { + return GetExtendedDayPeriod(dateTime.Hour); + } + + // Otherwise, format with other components and append day period + string formatted; + if (parts.Count > 0) + { + var formatString = BuildFormatString(parts); + formatted = dateTime.ToString(formatString, CultureInfo); + } + else + { + formatted = ""; + } + + // Append era if specified + if (eraValue != null) + { + if (formatted.Length > 0) + { + formatted += " " + eraValue; + } + else + { + formatted = eraValue; + } + } + + return formatted + " " + GetExtendedDayPeriod(dateTime.Hour); + } + + if (parts.Count == 0 && eraValue == null) + { + // Default format if no components specified + return dateTime.ToString("G", CultureInfo); + } + + // Join parts with appropriate separators + string result; + if (parts.Count > 0) + { + var formatString2 = BuildFormatString(parts); + result = dateTime.ToString(formatString2, CultureInfo); + } + else + { + result = ""; + } + + // Append era if specified + if (eraValue != null) + { + if (result.Length > 0) + { + result += " " + eraValue; + } + else + { + result = eraValue; + } + } + + return result; + } + + private string GetHourFormat() + { + if (HourCycle != null) + { + if (string.Equals(HourCycle, "h11", StringComparison.Ordinal) || + string.Equals(HourCycle, "h12", StringComparison.Ordinal)) + { + return "h12"; + } + if (string.Equals(HourCycle, "h23", StringComparison.Ordinal) || + string.Equals(HourCycle, "h24", StringComparison.Ordinal)) + { + return "h24"; + } + return "h12"; + } + + // Default based on locale's short time pattern + // If pattern contains uppercase H, locale uses 24-hour; lowercase h means 12-hour + var timePattern = CultureInfo.DateTimeFormat.ShortTimePattern; + return timePattern.Contains('H') ? "h24" : "h12"; + } + + private string BuildFormatString(List parts) + { + // Simple join - a more sophisticated implementation would use + // locale-specific patterns + var result = new ValueStringBuilder(); + var hasDate = false; + var hasTime = false; + + // Check if this format uses a textual month (affects separator choice) + var hasTextualMonth = Month is "short" or "long" or "narrow"; + + foreach (var part in parts) + { + if (part.Length == 0) + { + continue; + } + + var firstChar = part[0]; + + if (result.Length > 0) + { + // Add separator based on what we're joining + if (firstChar is 'h' or 'H' or 'm' or 's' or 'f' or 't') + { + if (!hasTime) + { + if (hasDate) + { + result.Append(' '); + } + hasTime = true; + } + else if (firstChar is not 't' and not 'f') + { + result.Append(':'); + } + else if (firstChar == 't') + { + result.Append(' '); + } + else if (firstChar == 'f') + { + result.Append('.'); + } + } + else if (firstChar == 'z') + { + result.Append(' '); + } + else + { + if (!hasDate) + { + hasDate = true; + } + else + { + // Use appropriate separator based on format type + if (hasTextualMonth) + { + // Textual month format: "Jan 3, 2019" + // Use space after month, comma-space before year + if (firstChar is 'y' or 'Y') + { + result.Append("', '"); // Literal ", " before year + } + else + { + result.Append(' '); // Space between other parts + } + } + else + { + // Numeric format: "1/3/2019" + // Use literal '/' by escaping with single quotes to avoid .NET's culture-specific date separator + result.Append("'/'"); + } + } + } + } + else + { + if (firstChar is 'h' or 'H' or 'm' or 's' or 'f') + { + hasTime = true; + } + else if (firstChar is not 't' and not 'z') + { + hasDate = true; + } + } + + result.Append(part); + } + + var formatString = result.ToString(); + + // In .NET, single character format strings are interpreted as standard format specifiers + // We need to prefix with % to indicate it's a custom format + if (formatString.Length == 1) + { + return "%" + formatString; + } + + return formatString; + } + + /// + /// Returns the formatted parts with their types for formatToParts. + /// + /// The .NET DateTime to format + /// Optional original JavaScript year (for dates outside .NET DateTime range) + internal List FormatToParts(DateTime dateTime, int? originalYear = null) + { + // Convert to specified timezone if one was provided + if (TimeZone != null) + { + dateTime = ConvertToTimeZone(dateTime, TimeZone); + } + + var result = new List(); + + if (DateStyle != null || TimeStyle != null) + { + // For style-based formatting, use a simpler approach + FormatStyleToParts(dateTime, result, originalYear); + } + else + { + FormatComponentsToParts(dateTime, result, originalYear); + } + + // Apply numbering system transliteration if not using Latin digits + if (NumberingSystem != null && !string.Equals(NumberingSystem, "latn", StringComparison.OrdinalIgnoreCase)) + { + for (var i = 0; i < result.Count; i++) + { + var part = result[i]; + var transliterated = Data.NumberingSystemData.TransliterateDigits(part.Value, NumberingSystem); + if (!ReferenceEquals(transliterated, part.Value)) + { + result[i] = new DateTimePart(part.Type, transliterated); + } + } + } + + return result; + } + + private void FormatStyleToParts(DateTime dateTime, List result, int? originalYear) + { + // For style-based formatting, decompose into proper parts + // Map styles to component options and use component-based parts generation + var hasDate = DateStyle != null; + var hasTime = TimeStyle != null; + + if (hasDate) + { + FormatDateStyleToParts(dateTime, result); + } + + if (hasDate && hasTime) + { + // Add separator between date and time + result.Add(new DateTimePart("literal", ", ")); + } + + if (hasTime) + { + FormatTimeStyleToParts(dateTime, result); + } + } + + private void FormatDateStyleToParts(DateTime dateTime, List result) + { + var style = DateStyle; + + // Check if using Chinese or Dangi calendar + var isChineseCalendar = string.Equals(Calendar, "chinese", StringComparison.OrdinalIgnoreCase); + var isDangiCalendar = string.Equals(Calendar, "dangi", StringComparison.OrdinalIgnoreCase); + var isLunisolarCalendar = isChineseCalendar || isDangiCalendar; + + // Get Chinese/Dangi calendar date if needed + ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null; + if (isLunisolarCalendar) + { + lunisolarDate = isChineseCalendar + ? ChineseCalendarHelper.GetChineseDate(dateTime) + : ChineseCalendarHelper.GetDangiDate(dateTime); + } + + // Full: weekday, month, day, year + // Long: month, day, year + // Medium: month, day, year (abbreviated) + // Short: month/day/year (numeric) + + if (string.Equals(style, "full", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("weekday", dateTime.ToString("dddd", CultureInfo))); + result.Add(new DateTimePart("literal", ", ")); + } + + if (string.Equals(style, "full", StringComparison.Ordinal) || + string.Equals(style, "long", StringComparison.Ordinal)) + { + if (lunisolarDate.HasValue) + { + AddLunisolarDateParts(result, lunisolarDate.Value, textualMonth: true); + } + else + { + result.Add(new DateTimePart("month", dateTime.ToString("MMMM", CultureInfo))); + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("day", dateTime.Day.ToString(CultureInfo))); + result.Add(new DateTimePart("literal", ", ")); + result.Add(new DateTimePart("year", dateTime.Year.ToString(CultureInfo))); + } + } + else if (string.Equals(style, "medium", StringComparison.Ordinal)) + { + if (lunisolarDate.HasValue) + { + AddLunisolarDateParts(result, lunisolarDate.Value, textualMonth: false); + } + else + { + result.Add(new DateTimePart("month", dateTime.ToString("MMM", CultureInfo))); + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("day", dateTime.Day.ToString(CultureInfo))); + result.Add(new DateTimePart("literal", ", ")); + result.Add(new DateTimePart("year", dateTime.Year.ToString(CultureInfo))); + } + } + else // short + { + if (lunisolarDate.HasValue) + { + AddLunisolarDateParts(result, lunisolarDate.Value, textualMonth: false, shortFormat: true); + } + else + { + result.Add(new DateTimePart("month", dateTime.Month.ToString(CultureInfo))); + result.Add(new DateTimePart("literal", "/")); + result.Add(new DateTimePart("day", dateTime.Day.ToString(CultureInfo))); + result.Add(new DateTimePart("literal", "/")); + result.Add(new DateTimePart("year", (dateTime.Year % 100).ToString("D2", CultureInfo))); + } + } + } + + /// + /// Adds date parts for Chinese/Dangi lunisolar calendars. + /// + private void AddLunisolarDateParts(List result, ChineseCalendarHelper.ChineseCalendarDate date, bool textualMonth, bool shortFormat = false) + { + var lang = Locale.Split('-')[0].ToLowerInvariant(); + var isChineseLocale = string.Equals(lang, "zh", StringComparison.Ordinal); + + // Month + result.Add(new DateTimePart("month", date.Month.ToString(CultureInfo))); + result.Add(new DateTimePart("literal", "/")); + + // Day + result.Add(new DateTimePart("day", date.Day.ToString(CultureInfo))); + result.Add(new DateTimePart("literal", "/")); + + // Year - use relatedYear and yearName for lunisolar calendars + if (shortFormat) + { + result.Add(new DateTimePart("relatedYear", (date.RelatedYear % 100).ToString("D2", CultureInfo))); + } + else + { + result.Add(new DateTimePart("relatedYear", date.RelatedYear.ToString(CultureInfo))); + } + + // Add yearName for Chinese locale + if (isChineseLocale && !shortFormat) + { + result.Add(new DateTimePart("yearName", date.YearName)); + result.Add(new DateTimePart("literal", "年")); + } + } + + private void FormatTimeStyleToParts(DateTime dateTime, List result) + { + var style = TimeStyle; + var use12Hour = IsUsing12HourFormat(); + var hour = use12Hour ? (dateTime.Hour % 12 == 0 ? 12 : dateTime.Hour % 12) : dateTime.Hour; + + // Hour + result.Add(new DateTimePart("hour", hour.ToString(CultureInfo))); + + // Minute (always for time styles) + result.Add(new DateTimePart("literal", ":")); + result.Add(new DateTimePart("minute", dateTime.Minute.ToString("D2", CultureInfo))); + + // Second (for medium, long, full) + if (!string.Equals(style, "short", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("literal", ":")); + result.Add(new DateTimePart("second", dateTime.Second.ToString("D2", CultureInfo))); + } + + // Day period (AM/PM) for 12-hour format + if (use12Hour) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("dayPeriod", dateTime.Hour < 12 ? "AM" : "PM")); + } + + // Time zone name (for long and full) + if (string.Equals(style, "full", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(true))); + } + else if (string.Equals(style, "long", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(false))); + } + } + + private bool IsUsing12HourFormat() + { + // Check hourCycle first + if (HourCycle != null) + { + return string.Equals(HourCycle, "h11", StringComparison.Ordinal) || + string.Equals(HourCycle, "h12", StringComparison.Ordinal); + } + + // Default based on locale - US uses 12-hour, most others use 24-hour + var lang = Locale.Split('-')[0].ToLowerInvariant(); + return string.Equals(lang, "en", StringComparison.Ordinal); + } + + private string GetTimeZoneDisplayName(bool longName) + { + if (TimeZone != null) + { + if (string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase)) + { + return longName ? "Coordinated Universal Time" : "UTC"; + } + // For other timezones, use the ID or a short form + var parts = TimeZone.Split('/'); + return longName ? TimeZone : parts[parts.Length - 1]; + } + return longName ? TimeZoneInfo.Local.DisplayName : TimeZoneInfo.Local.Id; + } + + private void FormatComponentsToParts(DateTime dateTime, List result, int? originalYear = null) + { + var hasDate = false; + var hasTime = false; + + // Check if using Chinese or Dangi calendar + var isChineseCalendar = string.Equals(Calendar, "chinese", StringComparison.OrdinalIgnoreCase); + var isDangiCalendar = string.Equals(Calendar, "dangi", StringComparison.OrdinalIgnoreCase); + var isLunisolarCalendar = isChineseCalendar || isDangiCalendar; + + // Get Chinese/Dangi calendar date if needed + ChineseCalendarHelper.ChineseCalendarDate? lunisolarDate = null; + if (isLunisolarCalendar) + { + lunisolarDate = isChineseCalendar + ? ChineseCalendarHelper.GetChineseDate(dateTime) + : ChineseCalendarHelper.GetDangiDate(dateTime); + } + + // Determine locale-specific date order and separators + var formatInfo = GetLocaleDateFormat(); + var dateOrder = formatInfo.DateOrder; + var dateSeparator = formatInfo.DateSeparator; + var hasTextualMonth = formatInfo.HasTextualMonth; + + // Weekday (first, if present) + if (Weekday != null) + { + var format = Weekday switch + { + "long" => "dddd", + "short" => "ddd", + "narrow" => "ddd", + _ => "ddd" + }; + result.Add(new DateTimePart("weekday", dateTime.ToString(format, CultureInfo))); + hasDate = true; + } + + // Add date components in locale-specific order + foreach (var component in dateOrder) + { + switch (component) + { + case 'M' when Month != null: + AddMonthPart(dateTime, result, ref hasDate, dateSeparator, hasTextualMonth, lunisolarDate); + break; + case 'd' when Day != null: + AddDayPart(dateTime, result, ref hasDate, dateSeparator, hasTextualMonth, lunisolarDate); + break; + case 'y' when Year != null: + AddYearPart(dateTime, result, ref hasDate, dateSeparator, hasTextualMonth, lunisolarDate, originalYear); + break; + } + } + + // Era (after date components) + if (Era != null) + { + var eraName = GetEraName(dateTime, Calendar ?? "gregory", Era, originalYear); + if (eraName != null) + { + if (result.Count > 0) + { + result.Add(new DateTimePart("literal", " ")); + } + result.Add(new DateTimePart("era", eraName)); + } + } + + // Hour + if (Hour != null) + { + if (result.Count > 0) + { + result.Add(new DateTimePart("literal", hasDate ? ", " : "")); + } + var use12Hour = string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal); + var format = Hour switch + { + "numeric" => use12Hour ? "%h" : "%H", // Use % for single character + "2-digit" => use12Hour ? "hh" : "HH", + _ => use12Hour ? "%h" : "%H" + }; + result.Add(new DateTimePart("hour", dateTime.ToString(format, CultureInfo))); + hasTime = true; + } + + // Minute - for time components, "numeric" typically uses 2-digit padding in most locales + if (Minute != null) + { + if (result.Count > 0 && hasTime) + { + result.Add(new DateTimePart("literal", ":")); + } + // Per ECMA-402, minute and second use 2-digit format for both "numeric" and "2-digit" + result.Add(new DateTimePart("minute", dateTime.Minute.ToString("D2", CultureInfo))); + hasTime = true; + } + + // Second - for time components, "numeric" typically uses 2-digit padding in most locales + if (Second != null) + { + if (result.Count > 0 && hasTime) + { + result.Add(new DateTimePart("literal", ":")); + } + // Per ECMA-402, minute and second use 2-digit format for both "numeric" and "2-digit" + result.Add(new DateTimePart("second", dateTime.Second.ToString("D2", CultureInfo))); + hasTime = true; + } + + // Fractional seconds + if (FractionalSecondDigits.HasValue && FractionalSecondDigits.Value > 0) + { + // Use the decimal separator for the numbering system (e.g., ٫ for Arabic) + var decimalSeparator = NumberingSystem != null + ? Data.NumberingSystemData.GetDecimalSeparator(NumberingSystem).ToString() + : "."; + result.Add(new DateTimePart("literal", decimalSeparator)); + // Use % prefix for single-character format to prevent it being interpreted as standard format + var format = FractionalSecondDigits.Value == 1 ? "%f" : new string('f', FractionalSecondDigits.Value); + result.Add(new DateTimePart("fractionalSecond", dateTime.ToString(format, CultureInfo))); + } + + // Day period (AM/PM or extended day periods) + if (DayPeriod != null) + { + // Extended day periods like "in the morning", "noon", etc. + if (result.Count > 0) + { + result.Add(new DateTimePart("literal", " ")); + } + result.Add(new DateTimePart("dayPeriod", GetExtendedDayPeriod(dateTime.Hour))); + } + else if (Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("dayPeriod", dateTime.ToString("tt", CultureInfo))); + } + + // Time zone name + if (TimeZoneName != null) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("timeZoneName", dateTime.ToString("zzz", CultureInfo))); + } + + // If no parts were added, use default format + if (result.Count == 0) + { + var formatted = dateTime.ToString("G", CultureInfo); + result.Add(new DateTimePart("literal", formatted)); + } + } + + /// + /// Gets the extended day period string based on the hour and dayPeriod style. + /// CLDR defines: night1 (21:00-05:59), morning1 (06:00-11:59), noon (12:00), + /// afternoon1 (12:01-17:59), evening1 (18:00-20:59) + /// + private string GetExtendedDayPeriod(int hour) + { + // For English locale (en), use CLDR day period names + // Other locales would need locale-specific data + var lang = Locale.Split('-')[0]; + + if (string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase)) + { + return DayPeriod switch + { + "long" => hour switch + { + >= 0 and < 6 => "at night", + >= 6 and < 12 => "in the morning", + 12 => "noon", + > 12 and < 18 => "in the afternoon", + >= 18 and < 21 => "in the evening", + _ => "at night" + }, + "short" => hour switch + { + >= 0 and < 6 => "at night", + >= 6 and < 12 => "in the morning", + 12 => "noon", + > 12 and < 18 => "in the afternoon", + >= 18 and < 21 => "in the evening", + _ => "at night" + }, + "narrow" => hour switch + { + >= 0 and < 6 => "at night", + >= 6 and < 12 => "in the morning", + 12 => "n", + > 12 and < 18 => "in the afternoon", + >= 18 and < 21 => "in the evening", + _ => "at night" + }, + _ => hour < 12 ? "AM" : "PM" + }; + } + + // Default: use AM/PM + return hour < 12 ? "AM" : "PM"; + } + + internal readonly struct DateTimePart + { + public DateTimePart(string type, string value) + { + Type = type; + Value = value; + } + + public string Type { get; } + public string Value { get; } + } +} diff --git a/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs b/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs index cffbf93795..83c58485dd 100644 --- a/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs +++ b/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs @@ -1447,6 +1447,13 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) return JsString.Empty; } + // Per ECMA-402, pass locales and options to element's toLocaleString + var locales = arguments.At(0); + var options = arguments.At(1); + var invokeArgs = !locales.IsUndefined() || !options.IsUndefined() + ? new[] { locales, options } + : System.Array.Empty(); + using var r = new ValueStringBuilder(); for (uint k = 0; k < len; k++) { @@ -1456,7 +1463,7 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) } if (array.TryGetValue(k, out var nextElement) && !nextElement.IsNullOrUndefined()) { - var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", [])); + var s = TypeConverter.ToString(Invoke(nextElement, "toLocaleString", invokeArgs)); r.Append(s); } }