diff --git a/Jint.Tests.Test262/IcuCldrProvider.cs b/Jint.Tests.Test262/IcuCldrProvider.cs index 8c5c76c013..b433b329bf 100644 --- a/Jint.Tests.Test262/IcuCldrProvider.cs +++ b/Jint.Tests.Test262/IcuCldrProvider.cs @@ -271,67 +271,20 @@ private static string MapToCldrUnit(string unit) public DateTimePatterns? GetDateTimePatterns(string locale, string? dateStyle, string? timeStyle) => _fallback.GetDateTimePatterns(locale, dateStyle, timeStyle); - public string[]? GetMonthNames(string locale, string style) - => _fallback.GetMonthNames(locale, style); + public string[]? GetMonthNames(string locale, string style, string? calendar) + => _fallback.GetMonthNames(locale, style, calendar); public string[]? GetWeekdayNames(string locale, string style) => _fallback.GetWeekdayNames(locale, style); - public string[]? GetDayPeriods(string locale, string style) - => _fallback.GetDayPeriods(locale, style); + public string[]? GetDayPeriods(string locale, string style, string? calendar) + => _fallback.GetDayPeriods(locale, style, calendar); - public string[]? GetEraNames(string locale, string style) - => _fallback.GetEraNames(locale, style); + public string[]? GetEraNames(string locale, string style, string? calendar) + => _fallback.GetEraNames(locale, style, calendar); // === Display Names === - public string? GetLanguageDisplayName(string locale, string code) - { - try - { - var displayLocale = new UCultureInfo(locale); - var codeLocale = new UCultureInfo(code); - var result = codeLocale.GetDisplayLanguage(displayLocale); - return string.IsNullOrEmpty(result) ? _fallback.GetLanguageDisplayName(locale, code) : result; - } - catch - { - return _fallback.GetLanguageDisplayName(locale, code); - } - } - - public string? GetRegionDisplayName(string locale, string code) - { - try - { - var displayLocale = new UCultureInfo(locale); - // Create a locale with just the region - var codeLocale = new UCultureInfo("und-" + code); - var result = codeLocale.GetDisplayCountry(displayLocale); - return string.IsNullOrEmpty(result) ? _fallback.GetRegionDisplayName(locale, code) : result; - } - catch - { - return _fallback.GetRegionDisplayName(locale, code); - } - } - - public string? GetScriptDisplayName(string locale, string code) - { - try - { - var displayLocale = new UCultureInfo(locale); - // Create a locale with just the script - var codeLocale = new UCultureInfo("und-" + code); - var result = codeLocale.GetDisplayScript(displayLocale); - return string.IsNullOrEmpty(result) ? _fallback.GetScriptDisplayName(locale, code) : result; - } - catch - { - return _fallback.GetScriptDisplayName(locale, code); - } - } - public string? GetCurrencyDisplayName(string locale, string code) => _fallback.GetCurrencyDisplayName(locale, code); diff --git a/Jint.Tests.Test262/Jint.Tests.Test262.csproj b/Jint.Tests.Test262/Jint.Tests.Test262.csproj index 8a973847ce..4e8893c722 100644 --- a/Jint.Tests.Test262/Jint.Tests.Test262.csproj +++ b/Jint.Tests.Test262/Jint.Tests.Test262.csproj @@ -22,6 +22,7 @@ + diff --git a/Jint.Tests.Test262/NodaTimeZoneProvider.cs b/Jint.Tests.Test262/NodaTimeZoneProvider.cs new file mode 100644 index 0000000000..c27de6bd67 --- /dev/null +++ b/Jint.Tests.Test262/NodaTimeZoneProvider.cs @@ -0,0 +1,530 @@ +#nullable enable + +using System.Numerics; +using Jint.Native.Temporal; +using NodaTime; +using NodaTime.TimeZones; + +namespace Jint.Tests.Test262; + +/// +/// NodaTime-based time zone provider for accurate IANA timezone support. +/// Provides sub-minute offset precision, precise historical DST transitions, +/// and comprehensive timezone alias resolution. +/// +internal sealed class NodaTimeZoneProvider : ITimeZoneProvider +{ + private static readonly IDateTimeZoneProvider TzdbProvider = DateTimeZoneProviders.Tzdb; + private static readonly TzdbDateTimeZoneSource TzdbSource = TzdbDateTimeZoneSource.Default; + + public static NodaTimeZoneProvider Instance { get; } = new(); + + private static readonly BigInteger NanosecondsPerTick = 100; + private static readonly BigInteger NanosecondsPerSecond = 1_000_000_000; + + public long GetOffsetNanosecondsFor(string timeZoneId, BigInteger epochNanoseconds) + { + if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) || + string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal)) + { + return 0; + } + + // Handle offset-based time zones + var parsedOffset = ParseOffsetString(timeZoneId); + if (parsedOffset.HasValue) + { + return (long)parsedOffset.Value.TotalMilliseconds * 1_000_000L; + } + + var zone = ResolveZone(timeZoneId); + if (zone is null) + { + throw new ArgumentException($"Unknown time zone: {timeZoneId}", nameof(timeZoneId)); + } + + var instant = EpochNanosecondsToInstant(epochNanoseconds); + var offset = zone.GetUtcOffset(instant); + return offset.Nanoseconds; + } + + public BigInteger[] GetPossibleInstantsFor( + string timeZoneId, + int year, int month, int day, + int hour, int minute, int second, + int millisecond, int microsecond, int nanosecond) + { + if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) || + string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal)) + { + return [LocalToEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, Offset.Zero)]; + } + + var parsedOffset = ParseOffsetString(timeZoneId); + if (parsedOffset.HasValue) + { + var msOffset = (long)parsedOffset.Value.TotalMilliseconds; + var offsetSeconds = (int)(msOffset / 1000); + var nodaOffset = Offset.FromSeconds(offsetSeconds); + return [LocalToEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, nodaOffset)]; + } + + var zone = ResolveZone(timeZoneId); + if (zone is null) + { + throw new ArgumentException($"Unknown time zone: {timeZoneId}", nameof(timeZoneId)); + } + + try + { + var localDateTime = new LocalDateTime(year, month, day, hour, minute, second) + .PlusMilliseconds(millisecond); + + var mapping = zone.MapLocal(localDateTime); + + switch (mapping.Count) + { + case 0: + // Gap (spring forward) - no valid instants + return []; + case 1: + { + // Unambiguous + var instant = mapping.Single(); + var epochNs = InstantToEpochNanoseconds(instant.ToInstant()); + // Add sub-millisecond precision + epochNs += (BigInteger)microsecond * 1000 + nanosecond; + return [epochNs]; + } + default: + { + // Ambiguous (fall back) - two instants + var first = mapping.First(); + var last = mapping.Last(); + var epochNs1 = InstantToEpochNanoseconds(first.ToInstant()); + var epochNs2 = InstantToEpochNanoseconds(last.ToInstant()); + epochNs1 += (BigInteger)microsecond * 1000 + nanosecond; + epochNs2 += (BigInteger)microsecond * 1000 + nanosecond; + var results = new[] { epochNs1, epochNs2 }; + Array.Sort(results); + return results; + } + } + } + catch (ArgumentOutOfRangeException) + { + // Date is outside NodaTime's representable range. + // Use the offset from the boundary instant (no DST at these extremes). + try + { + var boundaryInstant = year > 0 + ? Instant.FromUnixTimeTicks(NodaMaxTicks) + : Instant.FromUnixTimeTicks(NodaMinTicks); + var offset = zone.GetUtcOffset(boundaryInstant); + return [LocalToEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset)]; + } + catch + { + return []; + } + } + } + + public BigInteger? GetNextTransition(string timeZoneId, BigInteger epochNanoseconds) + { + if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) || + string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal)) + { + return null; + } + + var zone = ResolveZone(timeZoneId); + if (zone is null) + { + return null; + } + + var instant = EpochNanosecondsToInstant(epochNanoseconds); + var interval = zone.GetZoneInterval(instant); + + // Find the next transition where the UTC offset actually changes + // (skip rule changes that only affect abbreviation/DST flag but not offset) + while (interval.HasEnd) + { + var nextInterval = zone.GetZoneInterval(interval.End); + if (nextInterval.WallOffset != interval.WallOffset) + { + return InstantToEpochNanoseconds(interval.End); + } + interval = nextInterval; + } + + return null; + } + + public BigInteger? GetPreviousTransition(string timeZoneId, BigInteger epochNanoseconds) + { + if (string.Equals(timeZoneId, "UTC", StringComparison.Ordinal) || + string.Equals(timeZoneId, "Etc/UTC", StringComparison.Ordinal)) + { + return null; + } + + var zone = ResolveZone(timeZoneId); + if (zone is null) + { + return null; + } + + var instant = EpochNanosecondsToInstant(epochNanoseconds); + var interval = zone.GetZoneInterval(instant); + + // Find the previous transition where the UTC offset actually changed + // (skip rule changes that only affect abbreviation/DST flag but not offset) + while (interval.HasStart) + { + var transitionNs = InstantToEpochNanoseconds(interval.Start); + var prevInstant = interval.Start - Duration.FromNanoseconds(1); + var prevInterval = zone.GetZoneInterval(prevInstant); + + // Check nanosecond precision: transition+1ns should find this transition, + // but exactly at or before this transition should look further back + var isAtOrBefore = epochNanoseconds <= transitionNs; + + if (!isAtOrBefore && prevInterval.WallOffset != interval.WallOffset) + { + // This transition changed the offset and we're after it + return transitionNs; + } + + // Skip this transition - either it's a no-op or we're at/before it + interval = prevInterval; + } + + return null; + } + + public bool IsValidTimeZone(string timeZoneId) + { + if (string.IsNullOrEmpty(timeZoneId)) + return false; + + // Check for offset strings + if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-')) + { + return IsValidOffsetString(timeZoneId); + } + + return ResolveZone(timeZoneId) is not null; + } + + public string? CanonicalizeTimeZone(string timeZoneId) + { + if (string.IsNullOrEmpty(timeZoneId)) + return null; + + // Handle UTC variants + if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) + return "UTC"; + if (timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase)) + return "Etc/UTC"; + if (timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase)) + return "Etc/GMT"; + if (timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase)) + return "GMT"; + + // Handle Etc/GMT0 (IANA Link name, no sign) + if (timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase)) + return "Etc/GMT0"; + + // Handle Etc/GMT+N and Etc/GMT-N + if (timeZoneId.StartsWith("Etc/GMT", StringComparison.OrdinalIgnoreCase) && timeZoneId.Length > 7) + { + var suffix = timeZoneId.Substring(7); + if ((suffix[0] == '+' || suffix[0] == '-') && suffix.Length >= 2 && suffix.Length <= 3) + { + if (suffix.Length == 3 && suffix[1] == '0') + return null; + + if (int.TryParse(suffix.AsSpan(1), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var offset)) + { + var maxOffset = suffix[0] == '-' ? 14 : 12; + if (offset >= 0 && offset <= maxOffset) + return $"Etc/GMT{suffix}"; + } + } + return null; + } + + // Handle offset strings + if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-')) + { + return CanonicalizeOffsetString(timeZoneId); + } + + var zone = ResolveZone(timeZoneId); + if (zone is null) + return null; + + // Return the ID as resolved by NodaTime (which preserves IANA casing) + return zone.Id; + } + + public IReadOnlyCollection GetAvailableTimeZones() + { + // Only return primary/canonical IDs (where CanonicalIdMap maps them to themselves) + // This ensures supportedValuesOf('timeZone') returns only distinct canonical IDs + return TzdbSource.CanonicalIdMap + .Where(kvp => string.Equals(kvp.Key, kvp.Value, StringComparison.Ordinal)) + .Select(kvp => kvp.Key) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + } + + public string GetDefaultTimeZone() + { + try + { + var systemZone = DateTimeZoneProviders.Tzdb.GetSystemDefault(); + return systemZone.Id; + } + catch + { + return TimeZoneInfo.Local.Id; + } + } + + public string? GetPrimaryTimeZoneIdentifier(string timeZoneId) + { + if (string.IsNullOrEmpty(timeZoneId)) + return null; + + // UTC variants all map to "UTC" + if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/UCT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT+0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT-0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/Greenwich", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/Universal", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/Zulu", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("UCT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT+0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT-0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Greenwich", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Universal", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Zulu", StringComparison.OrdinalIgnoreCase)) + { + return "UTC"; + } + + // Offset strings + if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-')) + { + return CanonicalizeOffsetString(timeZoneId); + } + + // Use NodaTime's CanonicalIdMap for alias resolution + if (TzdbSource.CanonicalIdMap.TryGetValue(timeZoneId, out var canonicalId)) + { + return canonicalId; + } + + // Case-insensitive fallback + foreach (var kvp in TzdbSource.CanonicalIdMap) + { + if (string.Equals(kvp.Key, timeZoneId, StringComparison.OrdinalIgnoreCase)) + { + return kvp.Value; + } + } + + return CanonicalizeTimeZone(timeZoneId); + } + + private static DateTimeZone? ResolveZone(string timeZoneId) + { + try + { + return TzdbProvider[timeZoneId]; + } + catch (DateTimeZoneNotFoundException) + { + // Try case-insensitive lookup + foreach (var id in TzdbProvider.Ids) + { + if (string.Equals(id, timeZoneId, StringComparison.OrdinalIgnoreCase)) + { + return TzdbProvider[id]; + } + } + return null; + } + } + + // NodaTime's Instant tick range (from NodaTime source) + private const long NodaMinTicks = -3776735808000000000L; + private const long NodaMaxTicks = 2534023007999999999L; + + private static Instant EpochNanosecondsToInstant(BigInteger epochNanoseconds) + { + // NodaTime Instant has tick (100ns) precision, not nanosecond. + // Use floor division (toward negative infinity) so that sub-tick differences + // near transition boundaries map to the correct tick: + // e.g., transition_ns - 1 must map to the tick BEFORE the transition. + // BigInteger.DivRem truncates toward zero, which gives wrong results for negative values. + var ticks = FloorDiv(epochNanoseconds, NanosecondsPerTick); + + // Clamp to NodaTime's Instant tick range (Temporal limits can exceed it) + if (ticks > NodaMaxTicks) + { + return Instant.FromUnixTimeTicks(NodaMaxTicks); + } + if (ticks < NodaMinTicks) + { + return Instant.FromUnixTimeTicks(NodaMinTicks); + } + + return Instant.FromUnixTimeTicks((long)ticks); + } + + private static BigInteger FloorDiv(BigInteger a, BigInteger b) + { + var (quotient, remainder) = BigInteger.DivRem(a, b); + // Adjust when remainder is non-zero and signs differ + if (remainder != 0 && (remainder < 0) != (b < 0)) + { + quotient--; + } + return quotient; + } + + private static BigInteger InstantToEpochNanoseconds(Instant instant) + { + // Convert via ticks to preserve precision + var ticks = instant.ToUnixTimeTicks(); + return (BigInteger)ticks * NanosecondsPerTick; + } + + private static BigInteger LocalToEpochNanoseconds( + int year, int month, int day, + int hour, int minute, int second, + int millisecond, int microsecond, int nanosecond, + Offset offset) + { + // Calculate total nanoseconds from epoch, then subtract offset + var daysSinceEpoch = TemporalHelpers.IsoDateToDays(year, month, day); + BigInteger totalNs = daysSinceEpoch; + totalNs *= 24L * 60 * 60 * NanosecondsPerSecond; + totalNs += (BigInteger)hour * 60 * 60 * NanosecondsPerSecond; + totalNs += (BigInteger)minute * 60 * NanosecondsPerSecond; + totalNs += (BigInteger)second * NanosecondsPerSecond; + totalNs += (BigInteger)millisecond * 1_000_000; + totalNs += (BigInteger)microsecond * 1000; + totalNs += nanosecond; + totalNs -= (BigInteger)offset.Nanoseconds; + return totalNs; + } + + private static TimeSpan? ParseOffsetString(string input) + { + if (string.IsNullOrEmpty(input) || input.Length < 3) + return null; + + var sign = input[0]; + if (sign != '+' && sign != '-') + return null; + + var isNegative = sign == '-'; + int hours, minutes = 0, seconds = 0; + + if (input.Length == 3) + { + if (!int.TryParse(input.AsSpan(1, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out hours)) + return null; + } + else if (input.Length >= 6 && input[3] == ':') + { + if (!int.TryParse(input.AsSpan(1, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out hours) || + !int.TryParse(input.AsSpan(4, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out minutes)) + return null; + + if (input.Length >= 9 && input[6] == ':') + { + if (!int.TryParse(input.AsSpan(7, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out seconds)) + return null; + } + } + else if (input.Length >= 5) + { + if (!int.TryParse(input.AsSpan(1, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out hours) || + !int.TryParse(input.AsSpan(3, 2), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out minutes)) + return null; + } + else + { + return null; + } + + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) + return null; + + var offset = new TimeSpan(hours, minutes, seconds); + return isNegative ? -offset : offset; + } + + private static bool IsValidOffsetString(string timeZoneId) + { + // Valid formats: +HH (3 chars), +HH:MM (6 chars), +HHMM (5 chars) + if (timeZoneId.Length == 3 || timeZoneId.Length == 5 || (timeZoneId.Length == 6 && timeZoneId[3] == ':')) + { + var parsed = ParseOffsetString(timeZoneId); + if (parsed.HasValue) + { + var totalMinutes = Math.Abs(parsed.Value.TotalMinutes); + return totalMinutes <= 23 * 60 + 59; + } + } + return false; + } + + private static string? CanonicalizeOffsetString(string timeZoneId) + { + if (timeZoneId.Length == 3 && char.IsDigit(timeZoneId[1]) && char.IsDigit(timeZoneId[2])) + { + var parsed = ParseOffsetString(timeZoneId); + if (parsed.HasValue) + { + var totalMinutes = Math.Abs(parsed.Value.TotalMinutes); + if (totalMinutes <= 23 * 60 + 59) + return $"{timeZoneId}:00"; + } + } + else if (timeZoneId.Length == 6 && timeZoneId[3] == ':') + { + var parsed = ParseOffsetString(timeZoneId); + if (parsed.HasValue) + { + var totalMinutes = Math.Abs(parsed.Value.TotalMinutes); + if (totalMinutes <= 23 * 60 + 59) + return timeZoneId; + } + } + else if (timeZoneId.Length == 5 && char.IsDigit(timeZoneId[3])) + { + var parsed = ParseOffsetString(timeZoneId); + if (parsed.HasValue) + { + var totalMinutes = Math.Abs(parsed.Value.TotalMinutes); + if (totalMinutes <= 23 * 60 + 59) + return $"{timeZoneId.Substring(0, 3)}:{timeZoneId.Substring(3)}"; + } + } + return null; + } +} diff --git a/Jint.Tests.Test262/Test262Harness.settings.json b/Jint.Tests.Test262/Test262Harness.settings.json index 7702bd09be..0562c556ed 100644 --- a/Jint.Tests.Test262/Test262Harness.settings.json +++ b/Jint.Tests.Test262/Test262Harness.settings.json @@ -128,6 +128,7 @@ "intl402/DateTimeFormat/prototype/formatRangeToParts/chinese-calendar-dates.js", "intl402/DateTimeFormat/prototype/formatRangeToParts/dangi-calendar-dates.js", "intl402/DateTimeFormat/prototype/formatToParts/chinese-calendar-dates.js", + "intl402/DateTimeFormat/prototype/formatToParts/dangi-calendar-dates.js", // DurationFormat - precision formatting requires exact sub-second arithmetic "intl402/DurationFormat/prototype/format/precision-exact-mathematical-values.js", @@ -136,6 +137,7 @@ // NumberFormat - locale-specific formatting and formatRange "intl402/NumberFormat/prototype/format/format-significant-digits.js", + "intl402/NumberFormat/prototype/format/numbering-systems.js", "intl402/NumberFormat/prototype/format/useGrouping-extended-en-IN.js", "intl402/NumberFormat/prototype/format/value-decimal-string.js", // formatRange requires locale-specific CLDR range patterns and part collapsing @@ -155,22 +157,110 @@ "intl402/fallback-locales-are-supported.js", "intl402/supportedLocalesOf-consistent-with-resolvedOptions.js", - // misc features that require investigation - "intl402/NumberFormat/prototype/format/numbering-systems.js", + // === INTL402 TEMPORAL EXCLUSIONS === + // Non-ISO calendar arithmetic not yet implemented (Chinese/Dangi lunisolar, Hebrew, Persian, etc.) + // These require CalendarDateAdd, CalendarDateUntil, monthCode, monthsInYear, inLeapYear + // for non-Gregorian-based calendars. + + // Chinese calendar - lunisolar calendar arithmetic + "intl402/Temporal/PlainDate/prototype/inLeapYear/chinese-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/monthCode/chinese-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/monthsInYear/chinese-calendar-leap-days.js", + "intl402/Temporal/PlainDate/prototype/since/chinese-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/subtract/chinese-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/until/chinese-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/with/chinese-calendar-leap-dates.js", + "intl402/Temporal/PlainDateTime/prototype/inLeapYear/chinese-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/monthCode/chinese-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/monthsInYear/chinese-calendar-leap-days.js", + "intl402/Temporal/PlainDateTime/prototype/since/chinese-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/until/chinese-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/with/chinese-calendar-leap-dates.js", + "intl402/Temporal/PlainMonthDay/from/chinese-calendar-dates.js", + "intl402/Temporal/PlainMonthDay/prototype/monthCode/chinese-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/from/reference-day-chinese.js", + "intl402/Temporal/PlainYearMonth/prototype/inLeapYear/chinese-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/monthCode/chinese-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/monthsInYear/chinese-calendar-leap-days.js", + "intl402/Temporal/PlainYearMonth/prototype/since/chinese-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/until/chinese-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/with/chinese-calendar-leap-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/inLeapYear/chinese-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/monthCode/chinese-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/monthsInYear/chinese-calendar-leap-days.js", + "intl402/Temporal/ZonedDateTime/prototype/since/chinese-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/until/chinese-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/with/chinese-calendar-leap-dates.js", + + // Dangi (Korean) calendar - lunisolar calendar arithmetic + "intl402/Temporal/PlainDate/prototype/inLeapYear/dangi-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/monthCode/dangi-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/monthsInYear/dangi-calendar-leap-days.js", + "intl402/Temporal/PlainDate/prototype/since/dangi-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/subtract/dangi-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/until/dangi-calendar-dates.js", + "intl402/Temporal/PlainDate/prototype/with/dangi-calendar-leap-dates.js", + "intl402/Temporal/PlainDateTime/prototype/inLeapYear/dangi-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/monthCode/dangi-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/monthsInYear/dangi-calendar-leap-days.js", + "intl402/Temporal/PlainDateTime/prototype/since/dangi-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/until/dangi-calendar-dates.js", + "intl402/Temporal/PlainDateTime/prototype/with/dangi-calendar-leap-dates.js", + "intl402/Temporal/PlainMonthDay/from/dangi-30-day-leap-months.js", + "intl402/Temporal/PlainMonthDay/from/dangi-calendar-dates.js", + "intl402/Temporal/PlainMonthDay/prototype/monthCode/dangi-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/inLeapYear/dangi-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/monthCode/dangi-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/monthsInYear/dangi-calendar-leap-days.js", + "intl402/Temporal/PlainYearMonth/prototype/since/dangi-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/until/dangi-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/with/dangi-calendar-leap-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/inLeapYear/dangi-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/monthCode/dangi-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/monthsInYear/dangi-calendar-leap-days.js", + "intl402/Temporal/ZonedDateTime/prototype/since/dangi-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/until/dangi-calendar-dates.js", + "intl402/Temporal/ZonedDateTime/prototype/with/dangi-calendar-leap-dates.js", + + // Non-ISO calendar fields - Hebrew/multi-calendar with() operations + "intl402/Temporal/PlainDateTime/prototype/with/non-iso-calendar-fields.js", + "intl402/Temporal/PlainYearMonth/prototype/with/non-iso-calendar-fields.js", + "intl402/Temporal/ZonedDateTime/prototype/with/non-iso-calendar-fields.js", + + // Leap year since/until across non-ISO calendars (Chinese + Hebrew) + "intl402/Temporal/PlainDate/prototype/since/leap-year-since.js", + "intl402/Temporal/PlainDate/prototype/until/leap-year-until.js", + "intl402/Temporal/PlainDateTime/prototype/since/leap-year-since.js", + "intl402/Temporal/PlainDateTime/prototype/until/leap-year-until.js", + "intl402/Temporal/ZonedDateTime/prototype/since/leap-year-since.js", + "intl402/Temporal/ZonedDateTime/prototype/until/leap-year-until.js", + + // dateStyle with non-ISO calendar formatting (Islamic month names etc.) + "intl402/Temporal/Instant/prototype/toLocaleString/dateStyle.js", + "intl402/Temporal/PlainDate/prototype/toLocaleString/dateStyle.js", + "intl402/Temporal/PlainDateTime/prototype/toLocaleString/dateStyle.js", + "intl402/Temporal/PlainMonthDay/prototype/toLocaleString/dateStyle.js", + "intl402/Temporal/PlainYearMonth/prototype/toLocaleString/dateStyle.js", + "intl402/Temporal/ZonedDateTime/prototype/toLocaleString/dateStyle.js", + + // Persian calendar arithmetic + "intl402/Temporal/PlainDate/prototype/toString/basic-persian.js", + + // Calendar era handling - requires Chinese/Hebrew calendar arithmetic + "intl402/Temporal/PlainYearMonth/from/calendar-not-supporting-eras.js", + "intl402/Temporal/ZonedDateTime/from/calendar-not-supporting-eras.js", + + // Hebrew calendar arithmetic (reference-day, add/subtract with leap months) + "intl402/Temporal/PlainYearMonth/from/reference-day-hebrew.js", + "intl402/Temporal/PlainYearMonth/prototype/add/options-undefined.js", + "intl402/Temporal/PlainYearMonth/prototype/subtract/options-undefined.js", + + // Japanese calendar era boundary resolution + "intl402/Temporal/PlainYearMonth/from/reference-day-japanese.js", - // Temporal - "intl402/Temporal/Duration/**/*.js", - "intl402/Temporal/Instant/**/*.js", - "intl402/Temporal/PlainDate/**/*.js", - "intl402/Temporal/PlainDateTime/**/*.js", - "intl402/Temporal/PlainMonthDay/**/*.js", - "intl402/Temporal/PlainTime/**/*.js", - "intl402/Temporal/PlainYearMonth/**/*.js", - "intl402/Temporal/ZonedDateTime/**/*.js", - "intl402/DateTimeFormat/**/*temporal*.js", - "intl402/DurationFormat/**/*temporal*.js", - "intl402/DateTimeFormat/prototype/formatRange/to-datetime-formattable-with-different-arg-kinds.js", - "intl402/DateTimeFormat/prototype/formatRangeToParts/to-datetime-formattable-with-different-arg-kinds.js", - "intl402/DateTimeFormat/prototype/formatToParts/dangi-calendar-dates.js" + // PlainMonthDay from - multi-calendar (constrain, reference-date, reference-year) + "intl402/Temporal/PlainMonthDay/from/constrain-to-leap-day.js", + "intl402/Temporal/PlainMonthDay/from/reference-date-noniso-calendar.js", + "intl402/Temporal/PlainMonthDay/from/reference-year-1972.js" ] } \ No newline at end of file diff --git a/Jint.Tests.Test262/Test262Test.cs b/Jint.Tests.Test262/Test262Test.cs index d19161e7a3..18c456825e 100644 --- a/Jint.Tests.Test262/Test262Test.cs +++ b/Jint.Tests.Test262/Test262Test.cs @@ -24,6 +24,8 @@ private static Engine BuildTestExecutor(Test262File file, Test262AgentManager? a cfg.TimeoutInterval(TimeSpan.FromSeconds(30)); // Use ICU-based CLDR provider for better Intl support cfg.Intl.CldrProvider = IcuCldrProvider.Instance; + // Use NodaTime for accurate IANA timezone support (sub-minute offsets, historical DST) + cfg.Temporal.TimeZoneProvider = NodaTimeZoneProvider.Instance; }); if (file.Flags.Contains("raw")) diff --git a/Jint.Tests/Runtime/EngineTests.cs b/Jint.Tests/Runtime/EngineTests.cs index eaa0589a5a..ea855248e8 100644 --- a/Jint.Tests/Runtime/EngineTests.cs +++ b/Jint.Tests/Runtime/EngineTests.cs @@ -1851,9 +1851,9 @@ public void DateToStringMethodsShouldUseCurrentTimeZoneAndCulture() equal('Mon Jun 01 2015', d.toDateString()); equal('05:00:00 GMT-0700 (Pacific Standard Time)', d.toTimeString()); // ECMA-402 compliant: numeric defaults used when no options specified - equal('1/6/2015 5:00:00', d.toLocaleString()); + equal('1/6/2015, 05:00:00', d.toLocaleString()); equal('1/6/2015', d.toLocaleDateString()); - equal('5:00:00', d.toLocaleTimeString()); + equal('05:00:00', d.toLocaleTimeString()); "); } diff --git a/Jint/Native/Date/DatePrototype.cs b/Jint/Native/Date/DatePrototype.cs index 066a46338e..daad78509b 100644 --- a/Jint/Native/Date/DatePrototype.cs +++ b/Jint/Native/Date/DatePrototype.cs @@ -249,8 +249,9 @@ private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) } // Use Intl.DateTimeFormat for locale-aware formatting + // Pass UTC time; DTF handles timezone conversion based on its timeZone option var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat); - return dateTimeFormat.Format(ToLocalTime(dateInstance)); + return dateTimeFormat.Format(dateInstance.ToDateTime()); } /// @@ -287,8 +288,9 @@ private JsValue ToLocaleDateString(JsValue thisObject, JsCallArguments arguments } // Use Intl.DateTimeFormat for locale-aware formatting + // Pass UTC time; DTF handles timezone conversion based on its timeZone option var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat); - return dateTimeFormat.Format(ToLocalTime(dateInstance)); + return dateTimeFormat.Format(dateInstance.ToDateTime()); } /// @@ -325,8 +327,9 @@ private JsValue ToLocaleTimeString(JsValue thisObject, JsCallArguments arguments } // Use Intl.DateTimeFormat for locale-aware formatting + // Pass UTC time; DTF handles timezone conversion based on its timeZone option var dateTimeFormat = (JsDateTimeFormat) Engine.Realm.Intrinsics.DateTimeFormat.Construct([locales, options], Engine.Realm.Intrinsics.DateTimeFormat); - return dateTimeFormat.Format(ToLocalTime(dateInstance)); + return dateTimeFormat.Format(dateInstance.ToDateTime()); } /// @@ -341,14 +344,13 @@ private static bool NeedDateTimeDefaults(ObjectInstance options, bool checkDate, return false; } - // Check date-related properties + // Check date-related properties (era is intentionally not checked per spec) if (checkDate) { if (!options.Get("weekday").IsUndefined() || !options.Get("year").IsUndefined() || !options.Get("month").IsUndefined() || - !options.Get("day").IsUndefined() || - !options.Get("era").IsUndefined()) + !options.Get("day").IsUndefined()) { return false; } @@ -384,7 +386,7 @@ private static void CopyOptions(ObjectInstance source, ObjectInstance target) // Date components "weekday", "era", "year", "month", "day", // Time components - "dayPeriod", "hour", "minute", "second", "fractionalSecondDigits" + "dayPeriod", "hour", "minute", "second", "fractionalSecondDigits", "timeZoneName" }; foreach (var option in optionsToCopy) { diff --git a/Jint/Native/Intl/Data/MetaZoneData.cs b/Jint/Native/Intl/Data/MetaZoneData.cs new file mode 100644 index 0000000000..68c60e0bbf --- /dev/null +++ b/Jint/Native/Intl/Data/MetaZoneData.cs @@ -0,0 +1,649 @@ +using System.Runtime.InteropServices; + +namespace Jint.Native.Intl.Data; + +/// +/// CLDR metazone data for timezone display name resolution. +/// Maps IANA timezone IDs to metazones, and provides en-US display names. +/// Based on CLDR metaZones.xml and timeZoneNames.json for en-US locale. +/// +internal static class MetaZoneData +{ + [StructLayout(LayoutKind.Auto)] + internal readonly record struct MetaZoneNames( + string LongStandard, + string LongDaylight, + string LongGeneric, + string ShortStandard, + string ShortDaylight, + string ShortGeneric); + + /// + /// Maps IANA timezone IDs to their current CLDR metazone name. + /// + internal static readonly Dictionary IanaToMetaZone = new(StringComparer.OrdinalIgnoreCase) + { + // Europe_Central + ["Europe/Andorra"] = "Europe_Central", + ["Europe/Belgrade"] = "Europe_Central", + ["Europe/Berlin"] = "Europe_Central", + ["Europe/Bratislava"] = "Europe_Central", + ["Europe/Brussels"] = "Europe_Central", + ["Europe/Budapest"] = "Europe_Central", + ["Europe/Busingen"] = "Europe_Central", + ["Europe/Copenhagen"] = "Europe_Central", + ["Europe/Gibraltar"] = "Europe_Central", + ["Europe/Ljubljana"] = "Europe_Central", + ["Europe/Luxembourg"] = "Europe_Central", + ["Europe/Madrid"] = "Europe_Central", + ["Europe/Malta"] = "Europe_Central", + ["Europe/Monaco"] = "Europe_Central", + ["Europe/Oslo"] = "Europe_Central", + ["Europe/Paris"] = "Europe_Central", + ["Europe/Podgorica"] = "Europe_Central", + ["Europe/Prague"] = "Europe_Central", + ["Europe/Rome"] = "Europe_Central", + ["Europe/San_Marino"] = "Europe_Central", + ["Europe/Sarajevo"] = "Europe_Central", + ["Europe/Skopje"] = "Europe_Central", + ["Europe/Stockholm"] = "Europe_Central", + ["Europe/Tirane"] = "Europe_Central", + ["Europe/Vaduz"] = "Europe_Central", + ["Europe/Vatican"] = "Europe_Central", + ["Europe/Vienna"] = "Europe_Central", + ["Europe/Warsaw"] = "Europe_Central", + ["Europe/Zagreb"] = "Europe_Central", + ["Europe/Zurich"] = "Europe_Central", + ["Africa/Ceuta"] = "Europe_Central", + ["Africa/Tunis"] = "Europe_Central", + ["Arctic/Longyearbyen"] = "Europe_Central", + + // Europe_Eastern + ["Europe/Athens"] = "Europe_Eastern", + ["Europe/Bucharest"] = "Europe_Eastern", + ["Europe/Chisinau"] = "Europe_Eastern", + ["Europe/Helsinki"] = "Europe_Eastern", + ["Europe/Kyiv"] = "Europe_Eastern", + ["Europe/Mariehamn"] = "Europe_Eastern", + ["Europe/Nicosia"] = "Europe_Eastern", + ["Europe/Riga"] = "Europe_Eastern", + ["Europe/Sofia"] = "Europe_Eastern", + ["Europe/Tallinn"] = "Europe_Eastern", + ["Europe/Uzhgorod"] = "Europe_Eastern", + ["Europe/Vilnius"] = "Europe_Eastern", + ["Europe/Zaporozhye"] = "Europe_Eastern", + ["Africa/Cairo"] = "Europe_Eastern", + ["Asia/Nicosia"] = "Europe_Eastern", + ["Asia/Famagusta"] = "Europe_Eastern", + + // Europe_Western + ["Atlantic/Canary"] = "Europe_Western", + ["Atlantic/Faroe"] = "Europe_Western", + ["Atlantic/Madeira"] = "Europe_Western", + ["Europe/Lisbon"] = "Europe_Western", + + // GMT + ["Europe/London"] = "GMT", + ["Europe/Dublin"] = "GMT", + ["Atlantic/Reykjavik"] = "GMT", + ["Africa/Abidjan"] = "GMT", + ["Africa/Accra"] = "GMT", + ["Africa/Bamako"] = "GMT", + ["Africa/Banjul"] = "GMT", + ["Africa/Bissau"] = "GMT", + ["Africa/Conakry"] = "GMT", + ["Africa/Dakar"] = "GMT", + ["Africa/Freetown"] = "GMT", + ["Africa/Lome"] = "GMT", + ["Africa/Monrovia"] = "GMT", + ["Africa/Nouakchott"] = "GMT", + ["Africa/Ouagadougou"] = "GMT", + ["Africa/Sao_Tome"] = "GMT", + + // America_Eastern + ["America/New_York"] = "America_Eastern", + ["America/Detroit"] = "America_Eastern", + ["America/Indiana/Indianapolis"] = "America_Eastern", + ["America/Indiana/Marengo"] = "America_Eastern", + ["America/Indiana/Petersburg"] = "America_Eastern", + ["America/Indiana/Tell_City"] = "America_Eastern", + ["America/Indiana/Vevay"] = "America_Eastern", + ["America/Indiana/Vincennes"] = "America_Eastern", + ["America/Indiana/Winamac"] = "America_Eastern", + ["America/Iqaluit"] = "America_Eastern", + ["America/Kentucky/Louisville"] = "America_Eastern", + ["America/Kentucky/Monticello"] = "America_Eastern", + ["America/Nassau"] = "America_Eastern", + ["America/Nipigon"] = "America_Eastern", + ["America/Pangnirtung"] = "America_Eastern", + ["America/Port-au-Prince"] = "America_Eastern", + ["America/Thunder_Bay"] = "America_Eastern", + ["America/Toronto"] = "America_Eastern", + + // America_Central + ["America/Chicago"] = "America_Central", + ["America/Indiana/Knox"] = "America_Central", + ["America/Matamoros"] = "America_Central", + ["America/Menominee"] = "America_Central", + ["America/North_Dakota/Beulah"] = "America_Central", + ["America/North_Dakota/Center"] = "America_Central", + ["America/North_Dakota/New_Salem"] = "America_Central", + ["America/Rainy_River"] = "America_Central", + ["America/Rankin_Inlet"] = "America_Central", + ["America/Resolute"] = "America_Central", + ["America/Winnipeg"] = "America_Central", + ["America/Bahia_Banderas"] = "America_Central", + ["America/Belize"] = "America_Central", + ["America/Costa_Rica"] = "America_Central", + ["America/El_Salvador"] = "America_Central", + ["America/Guatemala"] = "America_Central", + ["America/Managua"] = "America_Central", + ["America/Merida"] = "America_Central", + ["America/Mexico_City"] = "America_Central", + ["America/Monterrey"] = "America_Central", + ["America/Regina"] = "America_Central", + ["America/Swift_Current"] = "America_Central", + ["America/Tegucigalpa"] = "America_Central", + + // America_Mountain + ["America/Denver"] = "America_Mountain", + ["America/Boise"] = "America_Mountain", + ["America/Cambridge_Bay"] = "America_Mountain", + ["America/Edmonton"] = "America_Mountain", + ["America/Inuvik"] = "America_Mountain", + ["America/Ojinaga"] = "America_Mountain", + ["America/Chihuahua"] = "America_Mountain", + ["America/Mazatlan"] = "America_Mountain", + ["America/Phoenix"] = "America_Mountain", + ["America/Creston"] = "America_Mountain", + ["America/Dawson_Creek"] = "America_Mountain", + ["America/Fort_Nelson"] = "America_Mountain", + + // America_Pacific + ["America/Los_Angeles"] = "America_Pacific", + ["America/Dawson"] = "America_Pacific", + ["America/Tijuana"] = "America_Pacific", + ["America/Vancouver"] = "America_Pacific", + ["America/Whitehorse"] = "America_Pacific", + + // Alaska + ["America/Anchorage"] = "Alaska", + ["America/Juneau"] = "Alaska", + ["America/Metlakatla"] = "Alaska", + ["America/Nome"] = "Alaska", + ["America/Sitka"] = "Alaska", + ["America/Yakutat"] = "Alaska", + + // Hawaii_Aleutian + ["Pacific/Honolulu"] = "Hawaii_Aleutian", + ["America/Adak"] = "Hawaii_Aleutian", + + // Atlantic + ["America/Halifax"] = "Atlantic", + ["America/Glace_Bay"] = "Atlantic", + ["America/Goose_Bay"] = "Atlantic", + ["America/Moncton"] = "Atlantic", + ["America/Thule"] = "Atlantic", + ["Atlantic/Bermuda"] = "Atlantic", + ["America/Barbados"] = "Atlantic", + ["America/Martinique"] = "Atlantic", + ["America/Puerto_Rico"] = "Atlantic", + ["America/Santo_Domingo"] = "Atlantic", + ["America/Virgin"] = "Atlantic", + + // Newfoundland + ["America/St_Johns"] = "Newfoundland", + + // Brasilia + ["America/Sao_Paulo"] = "Brasilia", + ["America/Araguaina"] = "Brasilia", + ["America/Bahia"] = "Brasilia", + ["America/Belem"] = "Brasilia", + ["America/Fortaleza"] = "Brasilia", + ["America/Maceio"] = "Brasilia", + ["America/Recife"] = "Brasilia", + ["America/Santarem"] = "Brasilia", + + // Amazon + ["America/Manaus"] = "Amazon", + ["America/Boa_Vista"] = "Amazon", + ["America/Campo_Grande"] = "Amazon", + ["America/Cuiaba"] = "Amazon", + ["America/Porto_Velho"] = "Amazon", + + // Argentina + ["America/Argentina/Buenos_Aires"] = "Argentina", + ["America/Argentina/Catamarca"] = "Argentina", + ["America/Argentina/Cordoba"] = "Argentina", + ["America/Argentina/Jujuy"] = "Argentina", + ["America/Argentina/La_Rioja"] = "Argentina", + ["America/Argentina/Mendoza"] = "Argentina", + ["America/Argentina/Rio_Gallegos"] = "Argentina", + ["America/Argentina/Salta"] = "Argentina", + ["America/Argentina/San_Juan"] = "Argentina", + ["America/Argentina/San_Luis"] = "Argentina", + ["America/Argentina/Tucuman"] = "Argentina", + ["America/Argentina/Ushuaia"] = "Argentina", + + // Moscow + ["Europe/Moscow"] = "Moscow", + ["Europe/Kirov"] = "Moscow", + ["Europe/Simferopol"] = "Moscow", + ["Europe/Volgograd"] = "Moscow", + + // Japan + ["Asia/Tokyo"] = "Japan", + + // Korea + ["Asia/Seoul"] = "Korea", + + // China + ["Asia/Shanghai"] = "China", + ["Asia/Macau"] = "China", + + // Taipei + ["Asia/Taipei"] = "Taipei", + + // Hong_Kong + ["Asia/Hong_Kong"] = "Hong_Kong", + + // India + ["Asia/Kolkata"] = "India", + ["Asia/Calcutta"] = "India", + + // Pakistan + ["Asia/Karachi"] = "Pakistan", + + // Bangladesh + ["Asia/Dhaka"] = "Bangladesh", + + // Gulf + ["Asia/Dubai"] = "Gulf", + ["Asia/Muscat"] = "Gulf", + + // Arabian + ["Asia/Riyadh"] = "Arabian", + ["Asia/Aden"] = "Arabian", + ["Asia/Baghdad"] = "Arabian", + ["Asia/Bahrain"] = "Arabian", + ["Asia/Kuwait"] = "Arabian", + ["Asia/Qatar"] = "Arabian", + + // Iran + ["Asia/Tehran"] = "Iran", + + // Israel + ["Asia/Jerusalem"] = "Israel", + + // Singapore + ["Asia/Singapore"] = "Singapore", + + // Indochina + ["Asia/Bangkok"] = "Indochina", + ["Asia/Ho_Chi_Minh"] = "Indochina", + ["Asia/Phnom_Penh"] = "Indochina", + ["Asia/Vientiane"] = "Indochina", + + // Indonesia_Western + ["Asia/Jakarta"] = "Indonesia_Western", + ["Asia/Pontianak"] = "Indonesia_Western", + + // Indonesia_Central + ["Asia/Makassar"] = "Indonesia_Central", + + // Indonesia_Eastern + ["Asia/Jayapura"] = "Indonesia_Eastern", + + // Philippines + ["Asia/Manila"] = "Philippines", + + // Malaysia + ["Asia/Kuala_Lumpur"] = "Malaysia", + + // Australia_Eastern + ["Australia/Sydney"] = "Australia_Eastern", + ["Australia/Melbourne"] = "Australia_Eastern", + ["Australia/Brisbane"] = "Australia_Eastern", + ["Australia/Hobart"] = "Australia_Eastern", + ["Australia/Currie"] = "Australia_Eastern", + ["Australia/Lindeman"] = "Australia_Eastern", + ["Antarctica/Macquarie"] = "Australia_Eastern", + + // Australia_Central + ["Australia/Adelaide"] = "Australia_Central", + ["Australia/Broken_Hill"] = "Australia_Central", + ["Australia/Darwin"] = "Australia_Central", + + // Australia_Western + ["Australia/Perth"] = "Australia_Western", + + // New_Zealand + ["Pacific/Auckland"] = "New_Zealand", + ["Antarctica/McMurdo"] = "New_Zealand", + + // Africa_Central + ["Africa/Maputo"] = "Africa_Central", + ["Africa/Blantyre"] = "Africa_Central", + ["Africa/Bujumbura"] = "Africa_Central", + ["Africa/Gaborone"] = "Africa_Central", + ["Africa/Harare"] = "Africa_Central", + ["Africa/Kigali"] = "Africa_Central", + ["Africa/Lubumbashi"] = "Africa_Central", + ["Africa/Lusaka"] = "Africa_Central", + ["Africa/Windhoek"] = "Africa_Central", + + // Africa_Eastern + ["Africa/Nairobi"] = "Africa_Eastern", + ["Africa/Addis_Ababa"] = "Africa_Eastern", + ["Africa/Asmera"] = "Africa_Eastern", + ["Africa/Dar_es_Salaam"] = "Africa_Eastern", + ["Africa/Djibouti"] = "Africa_Eastern", + ["Africa/Kampala"] = "Africa_Eastern", + ["Africa/Mogadishu"] = "Africa_Eastern", + ["Indian/Antananarivo"] = "Africa_Eastern", + ["Indian/Comoro"] = "Africa_Eastern", + ["Indian/Mayotte"] = "Africa_Eastern", + + // Africa_Southern + ["Africa/Johannesburg"] = "Africa_Southern", + ["Africa/Maseru"] = "Africa_Southern", + ["Africa/Mbabane"] = "Africa_Southern", + + // Africa_Western + ["Africa/Lagos"] = "Africa_Western", + ["Africa/Bangui"] = "Africa_Western", + ["Africa/Brazzaville"] = "Africa_Western", + ["Africa/Douala"] = "Africa_Western", + ["Africa/Kinshasa"] = "Africa_Western", + ["Africa/Libreville"] = "Africa_Western", + ["Africa/Luanda"] = "Africa_Western", + ["Africa/Malabo"] = "Africa_Western", + ["Africa/Ndjamena"] = "Africa_Western", + ["Africa/Niamey"] = "Africa_Western", + ["Africa/Porto-Novo"] = "Africa_Western", + + // Novosibirsk + ["Asia/Novosibirsk"] = "Novosibirsk", + + // Vladivostok + ["Asia/Vladivostok"] = "Vladivostok", + + // Yakutsk + ["Asia/Yakutsk"] = "Yakutsk", + ["Asia/Chita"] = "Yakutsk", + ["Asia/Khandyga"] = "Yakutsk", + + // Magadan + ["Asia/Magadan"] = "Magadan", + + // Kamchatka + ["Asia/Kamchatka"] = "Kamchatka", + + // Apia + ["Pacific/Apia"] = "Apia", + + // Tonga + ["Pacific/Tongatapu"] = "Tonga", + + // Fiji + ["Pacific/Fiji"] = "Fiji", + + // Chatham + ["Pacific/Chatham"] = "Chatham", + + // Gambier + ["Pacific/Gambier"] = "Gambier", + + // Marquesas + ["Pacific/Marquesas"] = "Marquesas", + + // Norfolk + ["Pacific/Norfolk"] = "Norfolk", + + // Colombia + ["America/Bogota"] = "Colombia", + + // Peru + ["America/Lima"] = "Peru", + + // Chile + ["America/Santiago"] = "Chile", + + // Paraguay + ["America/Asuncion"] = "Paraguay", + + // Uruguay + ["America/Montevideo"] = "Uruguay", + + // Bolivia + ["America/La_Paz"] = "Bolivia", + + // Venezuela + ["America/Caracas"] = "Venezuela", + + // Guyana + ["America/Guyana"] = "Guyana", + + // Suriname + ["America/Paramaribo"] = "Suriname", + + // French_Guiana + ["America/Cayenne"] = "French_Guiana", + + // Ecuador + ["America/Guayaquil"] = "Ecuador", + + // Galapagos + ["Pacific/Galapagos"] = "Galapagos", + + // East_Timor + ["Asia/Dili"] = "East_Timor", + + // Afghanistan + ["Asia/Kabul"] = "Afghanistan", + + // Nepal + ["Asia/Kathmandu"] = "Nepal", + + // Bhutan + ["Asia/Thimphu"] = "Bhutan", + + // Myanmar + ["Asia/Yangon"] = "Myanmar", + ["Asia/Rangoon"] = "Myanmar", + + // Lanka + ["Asia/Colombo"] = "Lanka", + + // Kazakhstan_Eastern + ["Asia/Almaty"] = "Kazakhstan_Eastern", + + // Kazakhstan_Western + ["Asia/Aqtau"] = "Kazakhstan_Western", + ["Asia/Aqtobe"] = "Kazakhstan_Western", + ["Asia/Atyrau"] = "Kazakhstan_Western", + ["Asia/Oral"] = "Kazakhstan_Western", + ["Asia/Qostanay"] = "Kazakhstan_Western", + ["Asia/Qyzylorda"] = "Kazakhstan_Western", + + // Uzbekistan + ["Asia/Tashkent"] = "Uzbekistan", + ["Asia/Samarkand"] = "Uzbekistan", + + // Turkmenistan + ["Asia/Ashgabat"] = "Turkmenistan", + + // Georgia + ["Asia/Tbilisi"] = "Georgia", + + // Azerbaijan + ["Asia/Baku"] = "Azerbaijan", + + // Armenia + ["Asia/Yerevan"] = "Armenia", + + // Yekaterinburg + ["Asia/Yekaterinburg"] = "Yekaterinburg", + + // Omsk + ["Asia/Omsk"] = "Omsk", + + // Krasnoyarsk + ["Asia/Krasnoyarsk"] = "Krasnoyarsk", + + // Irkutsk + ["Asia/Irkutsk"] = "Irkutsk", + + // Europe_Further_Eastern + ["Europe/Minsk"] = "Moscow", + + // Samoa (additional) + ["Pacific/Pago_Pago"] = "Samoa", + ["Pacific/Midway"] = "Samoa", + + // Cuba + ["America/Havana"] = "Cuba", + + // Mexico_Pacific + ["America/Hermosillo"] = "Mexico_Pacific", + + // Pierre_Miquelon + ["America/Miquelon"] = "Pierre_Miquelon", + + // Greenland_Western + ["America/Nuuk"] = "Greenland_Western", + + // Apia (Pacific/Apia mapped above under Samoa section - current metazone is Apia) + + }; + + /// + /// En-US display names for metazones. + /// From CLDR dates/timeZoneNames for en-US locale. + /// + internal static readonly Dictionary EnUsNames = new(StringComparer.Ordinal) + { + ["Europe_Central"] = new("Central European Standard Time", "Central European Summer Time", "Central European Time", "CET", "CEST", "CET"), + ["Europe_Eastern"] = new("Eastern European Standard Time", "Eastern European Summer Time", "Eastern European Time", "EET", "EEST", "EET"), + ["Europe_Western"] = new("Western European Standard Time", "Western European Summer Time", "Western European Time", "WET", "WEST", "WET"), + ["GMT"] = new("Greenwich Mean Time", "Greenwich Mean Time", "Greenwich Mean Time", "GMT", "GMT", "GMT"), + ["America_Eastern"] = new("Eastern Standard Time", "Eastern Daylight Time", "Eastern Time", "EST", "EDT", "ET"), + ["America_Central"] = new("Central Standard Time", "Central Daylight Time", "Central Time", "CST", "CDT", "CT"), + ["America_Mountain"] = new("Mountain Standard Time", "Mountain Daylight Time", "Mountain Time", "MST", "MDT", "MT"), + ["America_Pacific"] = new("Pacific Standard Time", "Pacific Daylight Time", "Pacific Time", "PST", "PDT", "PT"), + ["Alaska"] = new("Alaska Standard Time", "Alaska Daylight Time", "Alaska Time", "AKST", "AKDT", "AKT"), + ["Hawaii_Aleutian"] = new("Hawaii-Aleutian Standard Time", "Hawaii-Aleutian Daylight Time", "Hawaii-Aleutian Time", "HST", "HDT", "HAT"), + ["Atlantic"] = new("Atlantic Standard Time", "Atlantic Daylight Time", "Atlantic Time", "AST", "ADT", "AT"), + ["Newfoundland"] = new("Newfoundland Standard Time", "Newfoundland Daylight Time", "Newfoundland Time", "NST", "NDT", "NT"), + ["Brasilia"] = new("Brasilia Standard Time", "Brasilia Summer Time", "Brasilia Time", "BRT", "BRST", "BRT"), + ["Amazon"] = new("Amazon Standard Time", "Amazon Summer Time", "Amazon Time", "AMT", "AMST", "AMT"), + ["Argentina"] = new("Argentina Standard Time", "Argentina Summer Time", "Argentina Time", "ART", "ARST", "ART"), + ["Moscow"] = new("Moscow Standard Time", "Moscow Daylight Time", "Moscow Time", "MSK", "MSD", "MSK"), + ["Japan"] = new("Japan Standard Time", "Japan Daylight Time", "Japan Time", "JST", "JDT", "JST"), + ["Korea"] = new("Korean Standard Time", "Korean Daylight Time", "Korean Time", "KST", "KDT", "KST"), + ["China"] = new("China Standard Time", "China Daylight Time", "China Time", "CST", "CDT", "CT"), + ["Taipei"] = new("Taipei Standard Time", "Taipei Daylight Time", "Taipei Time", "CST", "CDT", "CT"), + ["Hong_Kong"] = new("Hong Kong Standard Time", "Hong Kong Summer Time", "Hong Kong Time", "HKT", "HKST", "HKT"), + ["India"] = new("India Standard Time", "India Daylight Time", "India Standard Time", "IST", "IDT", "IST"), + ["Pakistan"] = new("Pakistan Standard Time", "Pakistan Summer Time", "Pakistan Time", "PKT", "PKST", "PKT"), + ["Bangladesh"] = new("Bangladesh Standard Time", "Bangladesh Summer Time", "Bangladesh Time", "BST", "BSST", "BST"), + ["Gulf"] = new("Gulf Standard Time", "Gulf Daylight Time", "Gulf Time", "GST", "GDT", "GT"), + ["Arabian"] = new("Arabian Standard Time", "Arabian Daylight Time", "Arabian Time", "AST", "ADT", "AT"), + ["Iran"] = new("Iran Standard Time", "Iran Daylight Time", "Iran Time", "IRST", "IRDT", "IRT"), + ["Israel"] = new("Israel Standard Time", "Israel Daylight Time", "Israel Time", "IST", "IDT", "IT"), + ["Singapore"] = new("Singapore Standard Time", "Singapore Daylight Time", "Singapore Time", "SGT", "SGT", "SGT"), + ["Indochina"] = new("Indochina Time", "Indochina Time", "Indochina Time", "ICT", "ICT", "ICT"), + ["Indonesia_Western"] = new("Western Indonesia Time", "Western Indonesia Time", "Western Indonesia Time", "WIB", "WIB", "WIB"), + ["Indonesia_Central"] = new("Central Indonesia Time", "Central Indonesia Time", "Central Indonesia Time", "WITA", "WITA", "WITA"), + ["Indonesia_Eastern"] = new("Eastern Indonesia Time", "Eastern Indonesia Time", "Eastern Indonesia Time", "WIT", "WIT", "WIT"), + ["Philippines"] = new("Philippine Standard Time", "Philippine Summer Time", "Philippine Time", "PHT", "PHST", "PHT"), + ["Malaysia"] = new("Malaysia Time", "Malaysia Time", "Malaysia Time", "MYT", "MYT", "MYT"), + ["Australia_Eastern"] = new("Australian Eastern Standard Time", "Australian Eastern Daylight Time", "Australian Eastern Time", "AEST", "AEDT", "AET"), + ["Australia_Central"] = new("Australian Central Standard Time", "Australian Central Daylight Time", "Australian Central Time", "ACST", "ACDT", "ACT"), + ["Australia_Western"] = new("Australian Western Standard Time", "Australian Western Daylight Time", "Australian Western Time", "AWST", "AWDT", "AWT"), + ["New_Zealand"] = new("New Zealand Standard Time", "New Zealand Daylight Time", "New Zealand Time", "NZST", "NZDT", "NZT"), + ["Africa_Central"] = new("Central Africa Time", "Central Africa Time", "Central Africa Time", "CAT", "CAT", "CAT"), + ["Africa_Eastern"] = new("East Africa Time", "East Africa Time", "East Africa Time", "EAT", "EAT", "EAT"), + ["Africa_Southern"] = new("South Africa Standard Time", "South Africa Standard Time", "South Africa Time", "SAST", "SAST", "SAST"), + ["Africa_Western"] = new("West Africa Standard Time", "West Africa Summer Time", "West Africa Time", "WAT", "WAST", "WAT"), + ["Novosibirsk"] = new("Novosibirsk Standard Time", "Novosibirsk Summer Time", "Novosibirsk Time", "NOVT", "NOVST", "NOVT"), + ["Vladivostok"] = new("Vladivostok Standard Time", "Vladivostok Summer Time", "Vladivostok Time", "VLAT", "VLAST", "VLAT"), + ["Yakutsk"] = new("Yakutsk Standard Time", "Yakutsk Summer Time", "Yakutsk Time", "YAKT", "YAKST", "YAKT"), + ["Magadan"] = new("Magadan Standard Time", "Magadan Summer Time", "Magadan Time", "MAGT", "MAGST", "MAGT"), + ["Kamchatka"] = new("Kamchatka Standard Time", "Kamchatka Summer Time", "Kamchatka Time", "PETT", "PETST", "PETT"), + ["Fiji"] = new("Fiji Standard Time", "Fiji Summer Time", "Fiji Time", "FJT", "FJST", "FJT"), + ["Chatham"] = new("Chatham Standard Time", "Chatham Daylight Time", "Chatham Time", "CHAST", "CHADT", "CHAT"), + ["Tonga"] = new("Tonga Standard Time", "Tonga Summer Time", "Tonga Time", "TOT", "TOST", "TOT"), + ["Samoa"] = new("Samoa Standard Time", "Samoa Daylight Time", "Samoa Time", "SST", "SDT", "ST"), + ["Colombia"] = new("Colombia Standard Time", "Colombia Summer Time", "Colombia Time", "COT", "COST", "COT"), + ["Peru"] = new("Peru Standard Time", "Peru Summer Time", "Peru Time", "PET", "PEST", "PET"), + ["Chile"] = new("Chile Standard Time", "Chile Summer Time", "Chile Time", "CLT", "CLST", "CLT"), + ["Paraguay"] = new("Paraguay Standard Time", "Paraguay Summer Time", "Paraguay Time", "PYT", "PYST", "PYT"), + ["Uruguay"] = new("Uruguay Standard Time", "Uruguay Summer Time", "Uruguay Time", "UYT", "UYST", "UYT"), + ["Bolivia"] = new("Bolivia Time", "Bolivia Time", "Bolivia Time", "BOT", "BOT", "BOT"), + ["Venezuela"] = new("Venezuela Time", "Venezuela Time", "Venezuela Time", "VET", "VET", "VET"), + ["French_Guiana"] = new("French Guiana Time", "French Guiana Time", "French Guiana Time", "GFT", "GFT", "GFT"), + ["Guyana"] = new("Guyana Time", "Guyana Time", "Guyana Time", "GYT", "GYT", "GYT"), + ["Suriname"] = new("Suriname Time", "Suriname Time", "Suriname Time", "SRT", "SRT", "SRT"), + ["Ecuador"] = new("Ecuador Time", "Ecuador Time", "Ecuador Time", "ECT", "ECT", "ECT"), + ["Galapagos"] = new("Galapagos Time", "Galapagos Time", "Galapagos Time", "GALT", "GALT", "GALT"), + ["Afghanistan"] = new("Afghanistan Time", "Afghanistan Time", "Afghanistan Time", "AFT", "AFT", "AFT"), + ["Nepal"] = new("Nepal Time", "Nepal Time", "Nepal Time", "NPT", "NPT", "NPT"), + ["Bhutan"] = new("Bhutan Time", "Bhutan Time", "Bhutan Time", "BTT", "BTT", "BTT"), + ["Myanmar"] = new("Myanmar Time", "Myanmar Time", "Myanmar Time", "MMT", "MMT", "MMT"), + ["Lanka"] = new("Sri Lanka Time", "Sri Lanka Time", "Sri Lanka Time", "SLT", "SLT", "SLT"), + ["Kazakhstan_Eastern"] = new("East Kazakhstan Time", "East Kazakhstan Time", "East Kazakhstan Time", "ALMT", "ALMT", "ALMT"), + ["Kazakhstan_Western"] = new("West Kazakhstan Time", "West Kazakhstan Time", "West Kazakhstan Time", "AQTT", "AQTT", "AQTT"), + ["Uzbekistan"] = new("Uzbekistan Standard Time", "Uzbekistan Summer Time", "Uzbekistan Time", "UZT", "UZST", "UZT"), + ["Turkmenistan"] = new("Turkmenistan Standard Time", "Turkmenistan Summer Time", "Turkmenistan Time", "TMT", "TMST", "TMT"), + ["Georgia"] = new("Georgia Standard Time", "Georgia Summer Time", "Georgia Time", "GET", "GEST", "GET"), + ["Azerbaijan"] = new("Azerbaijan Standard Time", "Azerbaijan Summer Time", "Azerbaijan Time", "AZT", "AZST", "AZT"), + ["Armenia"] = new("Armenia Standard Time", "Armenia Summer Time", "Armenia Time", "AMT", "AMST", "AMT"), + ["Yekaterinburg"] = new("Yekaterinburg Standard Time", "Yekaterinburg Summer Time", "Yekaterinburg Time", "YEKT", "YEKST", "YEKT"), + ["Omsk"] = new("Omsk Standard Time", "Omsk Summer Time", "Omsk Time", "OMST", "OMSST", "OMST"), + ["Krasnoyarsk"] = new("Krasnoyarsk Standard Time", "Krasnoyarsk Summer Time", "Krasnoyarsk Time", "KRAT", "KRAST", "KRAT"), + ["Irkutsk"] = new("Irkutsk Standard Time", "Irkutsk Summer Time", "Irkutsk Time", "IRKT", "IRKST", "IRKT"), + ["Norfolk"] = new("Norfolk Island Standard Time", "Norfolk Island Daylight Time", "Norfolk Island Time", "NFT", "NFDT", "NFT"), + ["Marquesas"] = new("Marquesas Time", "Marquesas Time", "Marquesas Time", "MART", "MART", "MART"), + ["Gambier"] = new("Gambier Time", "Gambier Time", "Gambier Time", "GAMT", "GAMT", "GAMT"), + ["Cuba"] = new("Cuba Standard Time", "Cuba Daylight Time", "Cuba Time", "CST", "CDT", "CT"), + ["Mexico_Pacific"] = new("Mexican Pacific Standard Time", "Mexican Pacific Daylight Time", "Mexican Pacific Time", "MST", "MDT", "MT"), + ["Pierre_Miquelon"] = new("St. Pierre & Miquelon Standard Time", "St. Pierre & Miquelon Daylight Time", "St. Pierre & Miquelon Time", "PMST", "PMDT", "PMT"), + ["Greenland_Western"] = new("West Greenland Standard Time", "West Greenland Summer Time", "West Greenland Time", "WGT", "WGST", "WGT"), + ["Apia"] = new("Apia Standard Time", "Apia Daylight Time", "Apia Time", "WSST", "WSDT", "WST"), + ["East_Timor"] = new("East Timor Time", "East Timor Time", "East Timor Time", "TLT", "TLT", "TLT"), + }; + + /// + /// Resolves the CLDR timezone display name for a given IANA timezone ID. + /// + /// The IANA timezone identifier + /// Whether daylight saving time is currently in effect + /// True for long format, false for short format + /// True for generic (non-specific) format + /// The display name, or null if not found in CLDR data + internal static string? GetDisplayName(string ianaId, bool isDaylightSaving, bool longName, bool generic) + { + if (!IanaToMetaZone.TryGetValue(ianaId, out var metaZone)) + { + return null; + } + + if (!EnUsNames.TryGetValue(metaZone, out var names)) + { + return null; + } + + if (generic) + { + return longName ? names.LongGeneric : names.ShortGeneric; + } + + if (isDaylightSaving) + { + return longName ? names.LongDaylight : names.ShortDaylight; + } + + return longName ? names.LongStandard : names.ShortStandard; + } +} diff --git a/Jint/Native/Intl/Data/TimeZoneData.cs b/Jint/Native/Intl/Data/TimeZoneData.cs index a7f76f4b0a..3a8b1e7ce6 100644 --- a/Jint/Native/Intl/Data/TimeZoneData.cs +++ b/Jint/Native/Intl/Data/TimeZoneData.cs @@ -65,13 +65,14 @@ internal static class TimeZoneData "America/St_Johns", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Tijuana", "America/Toronto", "America/Vancouver", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", - "America/Yellowknife", + // Note: America/Yellowknife is a Link to America/Edmonton since IANA TZDB 2022b "Antarctica/Casey", "Antarctica/Davis", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/Troll", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Atyrau", "Asia/Baghdad", "Asia/Baku", "Asia/Bangkok", - "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita", "Asia/Choibalsan", + "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita", + // Note: Asia/Choibalsan is a Link to Asia/Ulaanbaatar since IANA TZDB 2022b "Asia/Colombo", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Hebron", "Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", @@ -94,7 +95,8 @@ internal static class TimeZoneData "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/Perth", "Australia/Sydney", - "CET", "CST6CDT", "EET", "EST", "EST5EDT", + // Note: CET, CST6CDT, EET, EST, EST5EDT, HST, MET, MST, MST7MDT, PST8PDT, WET + // are Links in modern IANA TZDB (canonical-tz) - not included in supportedValuesOf // Note: Etc/GMT and Etc/UTC are NOT canonical - they are valid identifiers // but canonicalize to UTC for supportedValuesOf purposes "Etc/GMT+1", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12", @@ -113,10 +115,7 @@ internal static class TimeZoneData "Europe/Simferopol", "Europe/Sofia", "Europe/Tallinn", "Europe/Tirane", "Europe/Ulyanovsk", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zurich", - "HST", "Indian/Chagos", "Indian/Maldives", "Indian/Mauritius", - "MET", "MST", "MST7MDT", - "PST8PDT", "Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville", "Pacific/Chatham", "Pacific/Easter", "Pacific/Efate", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Galapagos", "Pacific/Gambier", @@ -126,8 +125,7 @@ internal static class TimeZoneData "Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn", "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", - "UTC", - "WET" + "UTC" ]; /// @@ -183,13 +181,12 @@ internal static class TimeZoneData "America/St_Johns", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Tijuana", "America/Toronto", "America/Vancouver", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", - "America/Yellowknife", "Antarctica/Casey", "Antarctica/Davis", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/Troll", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Atyrau", "Asia/Baghdad", "Asia/Baku", "Asia/Bangkok", - "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita", "Asia/Choibalsan", + "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Chita", "Asia/Colombo", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Hebron", "Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", @@ -212,7 +209,6 @@ internal static class TimeZoneData "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/Perth", "Australia/Sydney", - "CET", "CST6CDT", "EET", "EST", "EST5EDT", // Per ECMA-402 2024 spec, Etc/GMT and Etc/UTC should be preserved (not canonicalized to UTC) // when returned in resolvedOptions().timeZone "Etc/GMT", "Etc/UTC", @@ -232,10 +228,7 @@ internal static class TimeZoneData "Europe/Simferopol", "Europe/Sofia", "Europe/Tallinn", "Europe/Tirane", "Europe/Ulyanovsk", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zurich", - "HST", "Indian/Chagos", "Indian/Maldives", "Indian/Mauritius", - "MET", "MST", "MST7MDT", - "PST8PDT", "Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville", "Pacific/Chatham", "Pacific/Easter", "Pacific/Efate", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Galapagos", "Pacific/Gambier", @@ -246,9 +239,11 @@ internal static class TimeZoneData "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", "UTC", - "WET", // IANA TZDB Link names (aliases - must also be supported) + // POSIX-style zones (Links in canonical-tz IANA TZDB) + "CET", "CST6CDT", "EET", "EST", "EST5EDT", "HST", + "MET", "MST", "MST7MDT", "PST8PDT", "WET", "Africa/Accra", "Africa/Addis_Ababa", "Africa/Asmara", "Africa/Asmera", "Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Blantyre", "Africa/Brazzaville", "Africa/Bujumbura", "Africa/Conakry", "Africa/Dakar", @@ -273,12 +268,12 @@ internal static class TimeZoneData "America/Santa_Isabel", "America/Shiprock", "America/St_Barthelemy", "America/St_Kitts", "America/St_Lucia", "America/St_Thomas", "America/St_Vincent", "America/Thunder_Bay", "America/Tortola", - "America/Virgin", + "America/Virgin", "America/Yellowknife", "Antarctica/DumontDUrville", "Antarctica/McMurdo", "Antarctica/South_Pole", "Antarctica/Syowa", "Antarctica/Vostok", "Arctic/Longyearbyen", "Asia/Aden", "Asia/Ashkhabad", "Asia/Bahrain", "Asia/Brunei", - "Asia/Calcutta", "Asia/Chongqing", "Asia/Chungking", "Asia/Dacca", + "Asia/Calcutta", "Asia/Choibalsan", "Asia/Chongqing", "Asia/Chungking", "Asia/Dacca", "Asia/Harbin", "Asia/Istanbul", "Asia/Kashgar", "Asia/Katmandu", "Asia/Kuala_Lumpur", "Asia/Kuwait", "Asia/Macao", "Asia/Muscat", "Asia/Phnom_Penh", "Asia/Rangoon", "Asia/Saigon", "Asia/Tel_Aviv", diff --git a/Jint/Native/Intl/DateTimeFormatConstructor.cs b/Jint/Native/Intl/DateTimeFormatConstructor.cs index 87baa5a63c..9f8a783236 100644 --- a/Jint/Native/Intl/DateTimeFormatConstructor.cs +++ b/Jint/Native/Intl/DateTimeFormatConstructor.cs @@ -8,6 +8,18 @@ namespace Jint.Native.Intl; +/// +/// Per spec CreateDateTimeFormat 'required' parameter. +/// https://tc39.es/proposal-temporal/#sec-createdatetimeformat +/// +internal enum DateTimeRequired { Date, Time, YearMonth, MonthDay, Any } + +/// +/// Per spec CreateDateTimeFormat 'defaults' parameter. +/// https://tc39.es/proposal-temporal/#sec-createdatetimeformat +/// +internal enum DateTimeDefaults { Date, Time, YearMonth, MonthDay, ZonedDateTime, All } + /// /// https://tc39.es/ecma402/#sec-intl-datetimeformat-constructor /// @@ -77,6 +89,23 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT { var locales = arguments.At(0); var options = arguments.At(1); + return CreateDateTimeFormat(locales, options, newTarget); + } + + /// + /// CreateDateTimeFormat per spec, with optional required/defaults/toLocaleStringTimeZone parameters + /// for Temporal toLocaleString support. + /// https://tc39.es/proposal-temporal/#sec-createdatetimeformat + /// + internal JsDateTimeFormat CreateDateTimeFormat( + JsValue locales, + JsValue options, + JsValue? newTarget = null, + DateTimeRequired required = DateTimeRequired.Any, + DateTimeDefaults defaults = DateTimeDefaults.Date, + string? toLocaleStringTimeZone = null) + { + newTarget ??= this; // Get options object var optionsObj = IntlUtilities.CoerceOptionsToObject(_engine, options); @@ -277,8 +306,31 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT preserveHourCycleExt ? extHourCycle : null, preserveNumberingSystemExt ? extNumberingSystem : null); - // Step 29: Get timeZone option - var timeZone = GetTimeZoneOption(optionsObj); + // Step 29: Get timeZone option (read once to avoid double property access) + var timeZoneValue = optionsObj.Get("timeZone"); + string? timeZone; + if (timeZoneValue.IsUndefined()) + { + if (toLocaleStringTimeZone != null) + { + // Per ECMA-402 CanonicalizeTimeZoneName, resolve to primary identifier for formatting + // (Temporal preserves non-primary names in timeZoneId, but Intl canonicalizes to primary) + var provider = _engine.Options.Temporal.TimeZoneProvider; + timeZone = provider.GetPrimaryTimeZoneIdentifier(toLocaleStringTimeZone) ?? toLocaleStringTimeZone; + } + else + { + timeZone = null; // Will use system default + } + } + else + { + if (toLocaleStringTimeZone != null) + { + Throw.TypeError(_realm, "TimeZone option is not allowed when formatting a Temporal.ZonedDateTime"); + } + timeZone = ValidateAndCanonicalizeTimeZone(TypeConverter.ToString(timeZoneValue)); + } // 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 @@ -294,6 +346,13 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT var fractionalSecondDigits = GetNumberOption(optionsObj, "fractionalSecondDigits", 1, 3, null); var timeZoneName = GetStringOption(optionsObj, "timeZoneName", TimeZoneNameValues, null); + // Track whether user explicitly specified any core component option (before defaults are applied). + // Per spec, era and timeZoneName are supplementary options - they don't participate in + // overlap checking for Temporal types or prevent defaults from being applied. + var hasExplicitFormatComponents = weekday != null || year != null || + month != null || day != null || dayPeriod != null || hour != null || + minute != null || second != null || fractionalSecondDigits != null; + // Step 37: Get formatMatcher option GetStringOption(optionsObj, "formatMatcher", FormatMatcherValues, null); @@ -310,20 +369,108 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT { Throw.TypeError(_realm, "Can't set date/time component options when dateStyle or timeStyle is used"); } + + // Per spec: required=~date~/~year-month~/~month-day~ + timeStyle → TypeError + // required=~time~ + dateStyle → TypeError + if ((required == DateTimeRequired.Date || required == DateTimeRequired.YearMonth || required == DateTimeRequired.MonthDay) + && timeStyle != null) + { + Throw.TypeError(_realm, "timeStyle is not allowed when formatting a date-only Temporal type"); + } + if (required == DateTimeRequired.Time && dateStyle != null) + { + Throw.TypeError(_realm, "dateStyle is not allowed when formatting a time-only Temporal type"); + } } 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) + // Determine needDefaults based on required parameter per spec GetDateTimeFormat + var needDefaults = true; + switch (required) { - year = "numeric"; - month = "numeric"; - day = "numeric"; + case DateTimeRequired.Date: + if (weekday != null || year != null || month != null || day != null) + needDefaults = false; + break; + case DateTimeRequired.Time: + if (dayPeriod != null || hour != null || minute != null || second != null || fractionalSecondDigits != null) + needDefaults = false; + break; + case DateTimeRequired.YearMonth: + if (year != null || month != null) + needDefaults = false; + break; + case DateTimeRequired.MonthDay: + if (month != null || day != null) + needDefaults = false; + break; + case DateTimeRequired.Any: + if (weekday != null || year != null || month != null || day != null || + dayPeriod != null || hour != null || minute != null || second != null || fractionalSecondDigits != null) + needDefaults = false; + break; + } + + if (needDefaults) + { + // Apply defaults based on defaults parameter per spec + switch (defaults) + { + case DateTimeDefaults.Date: + year = "numeric"; + month = "numeric"; + day = "numeric"; + break; + case DateTimeDefaults.Time: + hour = "numeric"; + minute = "numeric"; + second = "numeric"; + break; + case DateTimeDefaults.YearMonth: + year = "numeric"; + month = "numeric"; + break; + case DateTimeDefaults.MonthDay: + month = "numeric"; + day = "numeric"; + break; + case DateTimeDefaults.ZonedDateTime: + case DateTimeDefaults.All: + year = "numeric"; + month = "numeric"; + day = "numeric"; + hour = "numeric"; + minute = "numeric"; + second = "numeric"; + if (defaults == DateTimeDefaults.ZonedDateTime && timeZoneName == null) + { + timeZoneName = "short"; + } + break; + } } } + // Per spec GetDateTimeFormat: filter out irrelevant components based on required. + // For time-only types, date components should not appear in the format. + // For date-only types, time components should not appear. + if (required == DateTimeRequired.Time) + { + weekday = null; + era = null; + year = null; + month = null; + day = null; + } + else if (required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.MonthDay) + { + dayPeriod = null; + hour = null; + minute = null; + second = null; + fractionalSecondDigits = null; + } + // Get DateTimeFormatInfo for the locale var culture = IntlUtilities.GetCultureInfo(resolvedLocale) ?? CultureInfo.InvariantCulture; var dateTimeFormatInfo = (DateTimeFormatInfo) culture.DateTimeFormat.Clone(); @@ -331,6 +478,10 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT // Get prototype from newTarget (for cross-realm construction) var proto = GetPrototypeFromConstructor(newTarget, static intrinsics => intrinsics.DateTimeFormat.PrototypeObject); + // Resolve default calendar from locale if not explicitly specified + // Per ECMA-402, the calendar is always resolved - defaults to "gregory" for most locales + calendar ??= GetDefaultCalendarForLocale(culture); + return new JsDateTimeFormat( _engine, proto, @@ -352,6 +503,7 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT second, fractionalSecondDigits, timeZoneName, + hasExplicitFormatComponents, dateTimeFormatInfo, culture); } @@ -421,8 +573,11 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT return null; } - var timeZone = TypeConverter.ToString(value); + return ValidateAndCanonicalizeTimeZone(TypeConverter.ToString(value)); + } + private string ValidateAndCanonicalizeTimeZone(string timeZone) + { // Validate and canonicalize time zone using IANA database // Per ECMA-402, only IANA timezone identifiers are allowed if (string.Equals(timeZone, "UTC", StringComparison.OrdinalIgnoreCase)) @@ -447,7 +602,7 @@ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newT // Not a valid IANA timezone identifier Throw.RangeError(_realm, $"Invalid time zone: {timeZone}"); - return null; + return null!; } /// @@ -554,6 +709,26 @@ private JsArray SupportedLocalesOf(JsValue thisObject, JsCallArguments arguments return new JsArray(_engine, supported.ToArray()); } + /// + /// Gets the default calendar for a locale. Per ECMA-402, most locales use "gregory". + /// Japanese locale uses "japanese", Thai uses "buddhist", etc. + /// + private static string GetDefaultCalendarForLocale(CultureInfo culture) + { + var calendarName = culture.Calendar.GetType().Name; + return calendarName switch + { + "JapaneseCalendar" => "japanese", + "ThaiBuddhistCalendar" => "buddhist", + "KoreanCalendar" => "korean", + "TaiwanCalendar" => "roc", + "HijriCalendar" or "UmAlQuraCalendar" => "islamic", + "HebrewCalendar" => "hebrew", + "PersianCalendar" => "persian", + _ => "gregory" + }; + } + /// /// Checks if the given calendar is supported. /// diff --git a/Jint/Native/Intl/DateTimeFormatPrototype.cs b/Jint/Native/Intl/DateTimeFormatPrototype.cs index e96283921f..4612fd7134 100644 --- a/Jint/Native/Intl/DateTimeFormatPrototype.cs +++ b/Jint/Native/Intl/DateTimeFormatPrototype.cs @@ -1,5 +1,7 @@ +using System.Numerics; using Jint.Native.Object; using Jint.Native.Symbol; +using Jint.Native.Temporal; using Jint.Runtime; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; @@ -77,8 +79,20 @@ private ClrFunction GetFormat(JsValue thisObject, JsCallArguments arguments) return new ClrFunction(Engine, "", (_, args) => { var dateValue = args.At(0); - var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear); - return dateTimeFormat.Format(dateTime, originalYear); + var formattable = ToDateTimeFormattable(dateValue); + + if (IsTemporalObject(formattable)) + { + var temporalDtf = GetTemporalFormatDtf(dateTimeFormat, formattable); + var dateTime = ConvertTemporalToDateTime(formattable); + var isPlain = formattable is not JsInstant; + return temporalDtf.Format(dateTime, isPlain: isPlain); + } + else + { + var dateTime = HandleDateTimeNonTemporal(dateTimeFormat, formattable, out var originalYear); + return dateTimeFormat.Format(dateTime, originalYear); + } }, 1, PropertyFlag.Configurable); } @@ -89,9 +103,21 @@ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments) { var dateTimeFormat = ValidateDateTimeFormat(thisObject); var dateValue = arguments.At(0); - var dateTime = ToDateTimeWithOriginalYear(dateValue, out var originalYear); + var formattable = ToDateTimeFormattable(dateValue); - var parts = dateTimeFormat.FormatToParts(dateTime, originalYear); + List parts; + if (IsTemporalObject(formattable)) + { + var temporalDtf = GetTemporalFormatDtf(dateTimeFormat, formattable); + var dateTime = ConvertTemporalToDateTime(formattable); + var isPlain = formattable is not JsInstant; + parts = temporalDtf.FormatToParts(dateTime, isPlain: isPlain); + } + else + { + var dateTime = HandleDateTimeNonTemporal(dateTimeFormat, formattable, out var originalYear); + parts = dateTimeFormat.FormatToParts(dateTime, originalYear); + } var result = new JsArray(Engine, (uint) parts.Count); for (var i = 0; i < parts.Count; i++) @@ -105,6 +131,449 @@ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments) return result; } + /// + /// https://tc39.es/ecma402/#sec-todatetimeformattable + /// Returns the Temporal object as-is if it is a Temporal object, otherwise converts to Number. + /// + private static JsValue ToDateTimeFormattable(JsValue value) + { + if (value.IsUndefined()) + { + return value; + } + + if (IsTemporalObject(value)) + { + return value; + } + + if (value is JsDate) + { + return value; + } + + return JsNumber.Create(TypeConverter.ToNumber(value)); + } + + /// + /// Returns true if the value is any Temporal type. + /// + private static bool IsTemporalObject(JsValue value) + { + return value is JsPlainDate or JsPlainDateTime or JsPlainTime + or JsPlainYearMonth or JsPlainMonthDay + or JsInstant or JsZonedDateTime or JsDuration; + } + + /// + /// Validates that range arguments are of the same Temporal type. + /// Per spec: If IsTemporalObject(x) or IsTemporalObject(y), then SameTemporalType(x, y) must be true. + /// + private void ValidateTemporalRangeTypes(JsValue x, JsValue y) + { + var xIsTemporal = IsTemporalObject(x); + var yIsTemporal = IsTemporalObject(y); + + if (!xIsTemporal && !yIsTemporal) + { + return; + } + + // One is temporal and the other isn't, or they are different types + if (!xIsTemporal || !yIsTemporal || x.GetType() != y.GetType()) + { + Throw.TypeError(_realm, "Both arguments must be the same Temporal type"); + } + } + + /// + /// Handles a non-temporal formattable value (Date, Number, undefined). + /// + private DateTime HandleDateTimeNonTemporal(JsDateTimeFormat dateTimeFormat, JsValue formattable, out int? originalYear) + { + originalYear = null; + return ToDateTimeWithOriginalYear(formattable, out originalYear); + } + + /// + /// Per spec HandleDateTimeValue + GetDateTimeFormat: computes the per-type format DTF + /// for a Temporal object. Each Temporal type uses a specific format record with its own + /// required/defaults, which may differ from the main DateTimeFormat record. + /// + private JsDateTimeFormat GetTemporalFormatDtf(JsDateTimeFormat dtf, JsValue temporal) + { + if (temporal is JsZonedDateTime) + { + Throw.TypeError(_realm, "Temporal.ZonedDateTime is not supported in DateTimeFormat.format(). Use toLocaleString() instead."); + return null!; + } + + // Plain types should not show timeZoneName + var isPlain = temporal is not JsInstant; + + // Determine per-type required and defaults + DateTimeRequired required; + DateTimeDefaults defaults; + switch (temporal) + { + case JsPlainDate: + required = DateTimeRequired.Date; + defaults = DateTimeDefaults.Date; + break; + case JsPlainDateTime: + required = DateTimeRequired.Any; + defaults = DateTimeDefaults.All; + break; + case JsPlainTime: + required = DateTimeRequired.Time; + defaults = DateTimeDefaults.Time; + break; + case JsPlainYearMonth: + required = DateTimeRequired.YearMonth; + defaults = DateTimeDefaults.YearMonth; + break; + case JsPlainMonthDay: + required = DateTimeRequired.MonthDay; + defaults = DateTimeDefaults.MonthDay; + break; + case JsInstant: + required = DateTimeRequired.Any; + defaults = DateTimeDefaults.All; + break; + default: + Throw.TypeError(_realm, $"Unsupported Temporal type: {temporal.GetType().Name}"); + return null!; + } + + // If the DTF uses dateStyle/timeStyle, validate restrictions and adjust for temporal type + if (dtf.DateStyle != null || dtf.TimeStyle != null) + { + if ((required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.MonthDay) && dtf.TimeStyle != null && dtf.DateStyle == null) + { + Throw.TypeError(_realm, "Cannot format a date-only Temporal type with timeStyle and no dateStyle"); + } + if (required == DateTimeRequired.Time && dtf.DateStyle != null && dtf.TimeStyle == null) + { + Throw.TypeError(_realm, "Cannot format a time-only Temporal type with dateStyle and no timeStyle"); + } + + // For date-only types: strip timeStyle; for time-only types: strip dateStyle + var effectiveDateStyle = dtf.DateStyle; + var effectiveTimeStyle = dtf.TimeStyle; + if (required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.MonthDay) + { + effectiveTimeStyle = null; + } + else if (required == DateTimeRequired.Time) + { + effectiveDateStyle = null; + } + + // For PlainYearMonth/PlainMonthDay, convert dateStyle to component-based formatting + // because these types need specific fields omitted (day for YearMonth, year for MonthDay) + if (effectiveDateStyle != null && required is DateTimeRequired.YearMonth or DateTimeRequired.MonthDay) + { + var isShort = string.Equals(effectiveDateStyle, "short", StringComparison.Ordinal); + var isMedium = string.Equals(effectiveDateStyle, "medium", StringComparison.Ordinal); + string? year = null, month = null, day = null; + + if (required == DateTimeRequired.YearMonth) + { + year = "numeric"; + month = isShort ? "numeric" : isMedium ? "short" : "long"; + } + else // MonthDay + { + month = isShort ? "numeric" : isMedium ? "short" : "long"; + day = "numeric"; + } + + return new JsDateTimeFormat( + _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem, + dtf.TimeZone, dtf.HourCycle, null, null, + null, null, year, month, day, null, null, null, null, null, + null, false, dtf.DateTimeFormatInfo, dtf.CultureInfo); + } + + // Return modified DTF if styles were stripped, otherwise return as-is + if (!string.Equals(effectiveDateStyle, dtf.DateStyle, StringComparison.Ordinal) + || !string.Equals(effectiveTimeStyle, dtf.TimeStyle, StringComparison.Ordinal)) + { + return new JsDateTimeFormat( + _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem, + dtf.TimeZone, dtf.HourCycle, effectiveDateStyle, effectiveTimeStyle, + dtf.Weekday, dtf.Era, dtf.Year, dtf.Month, dtf.Day, dtf.DayPeriod, + dtf.Hour, dtf.Minute, dtf.Second, dtf.FractionalSecondDigits, + dtf.TimeZoneName, dtf.HasExplicitFormatComponents, + dtf.DateTimeFormatInfo, dtf.CultureInfo); + } + return dtf; + } + + // Create per-type format DTF by filtering components relevant to the temporal type. + // This implements GetDateTimeFormat with inherit=~relevant~. + return CreatePerTypeFormatDtfFiltered(dtf, required, defaults, isPlain); + } + + /// + /// Creates a per-type format DTF by filtering components relevant to the temporal type. + /// Implements GetDateTimeFormat with inherit=~relevant~. + /// When HasExplicitFormatComponents is true: filter to relevant components, throw if no overlap. + /// When HasExplicitFormatComponents is false: apply per-type defaults (e.g., era-only case). + /// + private JsDateTimeFormat CreatePerTypeFormatDtfFiltered(JsDateTimeFormat dtf, DateTimeRequired required, DateTimeDefaults defaults, bool isPlain = false) + { + // Plain types should not show timeZoneName (only Instant has timezone) + + // Copy era if required includes date-like components + var era = (required is DateTimeRequired.Date or DateTimeRequired.YearMonth or DateTimeRequired.Any) + ? dtf.Era : null; + + if (!dtf.HasExplicitFormatComponents) + { + // No explicit component options from user (e.g., only era, or nothing). + // Apply per-type defaults - ignore construction defaults on the DTF. + string? year = null, month = null, day = null; + string? hour = null, minute = null, second = null; + string? timeZoneName = null; + + switch (defaults) + { + case DateTimeDefaults.Date: + year = "numeric"; month = "numeric"; day = "numeric"; + break; + case DateTimeDefaults.Time: + hour = "numeric"; minute = "numeric"; second = "numeric"; + break; + case DateTimeDefaults.YearMonth: + year = "numeric"; month = "numeric"; + break; + case DateTimeDefaults.MonthDay: + month = "numeric"; day = "numeric"; + break; + case DateTimeDefaults.ZonedDateTime: + case DateTimeDefaults.All: + year = "numeric"; month = "numeric"; day = "numeric"; + hour = "numeric"; minute = "numeric"; second = "numeric"; + if (defaults == DateTimeDefaults.ZonedDateTime && !isPlain) + timeZoneName = "short"; + break; + } + + return new JsDateTimeFormat( + _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem, + dtf.TimeZone, dtf.HourCycle, null, null, + null, era, year, month, day, null, hour, minute, second, null, + timeZoneName, false, dtf.DateTimeFormatInfo, dtf.CultureInfo); + } + + // User specified explicit component options - filter to relevant components + string? fWeekday = null, fYear = null, fMonth = null, fDay = null; + string? fDayPeriod = null, fHour = null, fMinute = null, fSecond = null; + int? fFractionalSecondDigits = null; + string? fTimeZoneName = isPlain ? null : dtf.TimeZoneName; + + bool anyPresent = false; + + switch (required) + { + case DateTimeRequired.Date: + fWeekday = dtf.Weekday; fYear = dtf.Year; fMonth = dtf.Month; fDay = dtf.Day; + anyPresent = fWeekday != null || fYear != null || fMonth != null || fDay != null; + break; + case DateTimeRequired.Time: + fDayPeriod = dtf.DayPeriod; fHour = dtf.Hour; fMinute = dtf.Minute; fSecond = dtf.Second; + fFractionalSecondDigits = dtf.FractionalSecondDigits; + anyPresent = fDayPeriod != null || fHour != null || fMinute != null || fSecond != null || fFractionalSecondDigits != null; + break; + case DateTimeRequired.YearMonth: + fYear = dtf.Year; fMonth = dtf.Month; + anyPresent = fYear != null || fMonth != null; + break; + case DateTimeRequired.MonthDay: + fMonth = dtf.Month; fDay = dtf.Day; + anyPresent = fMonth != null || fDay != null; + break; + case DateTimeRequired.Any: + fWeekday = dtf.Weekday; fYear = dtf.Year; fMonth = dtf.Month; fDay = dtf.Day; + fDayPeriod = dtf.DayPeriod; fHour = dtf.Hour; fMinute = dtf.Minute; fSecond = dtf.Second; + fFractionalSecondDigits = dtf.FractionalSecondDigits; + anyPresent = fWeekday != null || fYear != null || fMonth != null || fDay != null || + fDayPeriod != null || fHour != null || fMinute != null || fSecond != null || fFractionalSecondDigits != null; + break; + } + + if (!anyPresent) + { + // User explicitly set component options that don't overlap with this temporal type + Throw.TypeError(_realm, "DateTimeFormat options are incompatible with this Temporal type"); + return null!; + } + + return new JsDateTimeFormat( + _engine, dtf._prototype!, dtf.Locale, dtf.Calendar, dtf.NumberingSystem, + dtf.TimeZone, dtf.HourCycle, null, null, + fWeekday, era, fYear, fMonth, fDay, fDayPeriod, fHour, fMinute, fSecond, fFractionalSecondDigits, + fTimeZoneName, true, dtf.DateTimeFormatInfo, dtf.CultureInfo); + } + + /// + /// Converts a Temporal object to a .NET DateTime for formatting. + /// + private static DateTime ConvertTemporalToDateTime(JsValue temporal) + { + return temporal switch + { + JsPlainDate pd => new DateTime(pd.IsoDate.Year, pd.IsoDate.Month, pd.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), + JsPlainDateTime pdt => new DateTime(pdt.IsoDateTime.Date.Year, pdt.IsoDateTime.Date.Month, pdt.IsoDateTime.Date.Day, + pdt.IsoDateTime.Time.Hour, pdt.IsoDateTime.Time.Minute, pdt.IsoDateTime.Time.Second, + pdt.IsoDateTime.Time.Millisecond, DateTimeKind.Unspecified), + JsPlainTime pt => new DateTime(1970, 1, 1, pt.IsoTime.Hour, pt.IsoTime.Minute, pt.IsoTime.Second, + pt.IsoTime.Millisecond, DateTimeKind.Unspecified), + JsPlainYearMonth ym => new DateTime(ym.IsoDate.Year, ym.IsoDate.Month, ym.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), + JsPlainMonthDay md => new DateTime(md.IsoDate.Year, md.IsoDate.Month, md.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), + JsInstant inst => EpochNanosecondsToDateTime(inst.EpochNanoseconds), + _ => DateTime.Now + }; + } + + /// + /// Handles a formattable value for formatRange/formatRangeToParts. + /// + private DateTime HandleDateTimeTemporalOrOtherForRange(JsDateTimeFormat dateTimeFormat, JsValue formattable) + { + if (formattable is JsZonedDateTime) + { + Throw.TypeError(_realm, "Temporal.ZonedDateTime is not supported in DateTimeFormat.formatRange()."); + return default; + } + + if (formattable is JsPlainDate plainDate) + { + ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true); + return IsoDateToDateTime(plainDate.IsoDate); + } + + if (formattable is JsPlainDateTime plainDateTime) + { + return IsoDateTimeToDateTime(plainDateTime.IsoDateTime); + } + + if (formattable is JsPlainTime plainTime) + { + ValidateTemporalStyleRestrictions(dateTimeFormat, isTimeOnly: true); + return IsoTimeToDateTime(plainTime.IsoTime); + } + + if (formattable is JsPlainYearMonth yearMonth) + { + ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true); + return IsoDateToDateTime(yearMonth.IsoDate); + } + + if (formattable is JsPlainMonthDay monthDay) + { + ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true); + return IsoDateToDateTime(monthDay.IsoDate); + } + + if (formattable is JsInstant instant) + { + return EpochNanosecondsToDateTime(instant.EpochNanoseconds); + } + + return ToDateTimeForRange(formattable); + } + + /// + /// Validates style restrictions for Temporal "date-only" and "time-only" types. + /// PlainDate/PlainYearMonth/PlainMonthDay: timeStyle without dateStyle → TypeError + /// PlainTime: dateStyle without timeStyle → TypeError + /// When both styles present: the "wrong" one is simply ignored by the formatting. + /// + private void ValidateTemporalStyleRestrictions(JsDateTimeFormat dateTimeFormat, bool isDateOnly = false, bool isTimeOnly = false) + { + if (isDateOnly && dateTimeFormat.TimeStyle != null && dateTimeFormat.DateStyle == null) + { + Throw.TypeError(_realm, "Cannot format a date-only Temporal type with timeStyle and no dateStyle"); + } + + if (isTimeOnly && dateTimeFormat.DateStyle != null && dateTimeFormat.TimeStyle == null) + { + Throw.TypeError(_realm, "Cannot format a time-only Temporal type with dateStyle and no timeStyle"); + } + } + + /// + /// Converts an IsoDate to DateTime (time set to midnight). + /// + private static DateTime IsoDateToDateTime(IsoDate isoDate) + { + // Clamp to .NET DateTime range + if (isoDate.Year < 1 || isoDate.Year > 9999) + { + return isoDate.Year < 1 ? DateTime.MinValue : DateTime.MaxValue; + } + + return new DateTime(isoDate.Year, isoDate.Month, isoDate.Day, 0, 0, 0, DateTimeKind.Unspecified); + } + + /// + /// Converts an IsoDateTime to DateTime. + /// + private static DateTime IsoDateTimeToDateTime(IsoDateTime isoDateTime) + { + var date = isoDateTime.Date; + var time = isoDateTime.Time; + + // Clamp to .NET DateTime range + if (date.Year < 1 || date.Year > 9999) + { + return date.Year < 1 ? DateTime.MinValue : DateTime.MaxValue; + } + + return new DateTime(date.Year, date.Month, date.Day, + time.Hour, time.Minute, time.Second, + time.Millisecond, DateTimeKind.Unspecified); + } + + /// + /// Converts an IsoTime to DateTime using reference date 1970-01-01. + /// + private static DateTime IsoTimeToDateTime(IsoTime time) + { + return new DateTime(1970, 1, 1, time.Hour, time.Minute, time.Second, + time.Millisecond, DateTimeKind.Unspecified); + } + + /// + /// Converts epoch nanoseconds (BigInteger) to DateTime in UTC. + /// + private static DateTime EpochNanosecondsToDateTime(BigInteger epochNanoseconds) + { + const long nsPerTick = 100; // 1 tick = 100 nanoseconds + + // Convert to ticks since Unix epoch + var ticks = (long) (epochNanoseconds / nsPerTick); + + // Unix epoch in ticks + var unixEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + var totalTicks = unixEpochTicks + ticks; + + // Clamp to valid DateTime range + if (totalTicks < DateTime.MinValue.Ticks) + { + return DateTime.MinValue; + } + + if (totalTicks > DateTime.MaxValue.Ticks) + { + return DateTime.MaxValue; + } + + return new DateTime(totalTicks, DateTimeKind.Utc); + } + /// /// Converts a JavaScript value to DateTime, returning the original JavaScript year /// when the date is outside .NET DateTime range (for proper era formatting). @@ -209,7 +678,7 @@ private static double DayFromYear(int year) /// 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) + internal static string GetDefaultHourCycle(string locale) { // Most English-speaking locales use 12-hour format var lang = locale.Split('-')[0].ToLowerInvariant(); @@ -242,14 +711,7 @@ private JsObject ResolvedOptions(JsValue thisObject, JsCallArguments arguments) // 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("calendar", dateTimeFormat.Calendar ?? "gregory"); result.CreateDataPropertyOrThrow("numberingSystem", dateTimeFormat.NumberingSystem ?? "latn"); @@ -374,12 +836,24 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments) Throw.TypeError(_realm, "startDate and endDate are required"); } - var start = ToDateTimeForRange(startDate); - var end = ToDateTimeForRange(endDate); + // Per spec: ToDateTimeFormattable on both before checking kinds + var x = ToDateTimeFormattable(startDate); + var y = ToDateTimeFormattable(endDate); + + // Check SameTemporalType + ValidateTemporalRangeTypes(x, y); + + var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x); + var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y); + + // For Temporal objects, use per-type DTF and isPlain flag + var isTemporalInput = IsTemporalObject(x); + var isPlain = isTemporalInput && x is not JsInstant; + var effectiveDtf = isTemporalInput ? GetTemporalFormatDtf(dateTimeFormat, x) : dateTimeFormat; // Format both dates - var startFormatted = dateTimeFormat.Format(start); - var endFormatted = dateTimeFormat.Format(end); + var startFormatted = effectiveDtf.Format(start, isPlain: isPlain); + var endFormatted = effectiveDtf.Format(end, isPlain: isPlain); // If the dates are the same when formatted, return just one if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal)) @@ -387,60 +861,50 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments) return startFormatted; } - // Get parts for both dates to apply collapsing logic - var startParts = dateTimeFormat.FormatToParts(start); - var endParts = dateTimeFormat.FormatToParts(end); + // Get parts to determine shared prefix/suffix for collapsing + var startParts = effectiveDtf.FormatToParts(start, isPlain: isPlain); + var endParts = effectiveDtf.FormatToParts(end, isPlain: isPlain); - // Determine if we should use interval collapsing - var useCollapsing = ShouldUseIntervalCollapsing(dateTimeFormat, startParts, endParts); + var sharedPrefixEnd = FindNaturalBoundaryPrefix(startParts, endParts); + var sharedSuffixLen = FindNaturalBoundarySuffix(startParts, endParts, sharedPrefixEnd); - if (useCollapsing) + if (sharedPrefixEnd > 0 || sharedSuffixLen > 0) { - // 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; + var result = new System.Text.StringBuilder(); + var startUniqueEnd = startParts.Count - sharedSuffixLen; + var endUniqueEnd = endParts.Count - sharedSuffixLen; - if (hasSuffix) + // Shared prefix + for (var i = 0; i < sharedPrefixEnd; i++) { - 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); - } + result.Append(startParts[i].Value); + } - // Add separator - result.Append(" – "); + // Start range unique part + for (var i = sharedPrefixEnd; i < startUniqueEnd; i++) + { + result.Append(startParts[i].Value); + } - // Add end range differing parts - for (var i = sharedPrefixEnd; i < sharedSuffixStart; i++) - { - result.Append(endParts[i].Value); - } + result.Append(" \u2013 "); - // Add shared suffix - for (var i = sharedSuffixStart; i < startParts.Count; i++) - { - result.Append(startParts[i].Value); - } + // End range unique part + for (var i = sharedPrefixEnd; i < endUniqueEnd; i++) + { + result.Append(endParts[i].Value); + } - return result.ToString(); + // Shared suffix + for (var i = startUniqueEnd; i < startParts.Count; i++) + { + result.Append(startParts[i].Value); } + + return result.ToString(); } // Return a range string without collapsing - return $"{startFormatted} – {endFormatted}"; + return $"{startFormatted} \u2013 {endFormatted}"; } /// @@ -459,16 +923,28 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments Throw.TypeError(_realm, "startDate and endDate are required"); } - var start = ToDateTimeForRange(startDate); - var end = ToDateTimeForRange(endDate); + // Per spec: ToDateTimeFormattable on both before checking kinds + var x = ToDateTimeFormattable(startDate); + var y = ToDateTimeFormattable(endDate); + + // Check SameTemporalType + ValidateTemporalRangeTypes(x, y); + + var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x); + var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y); + + // For Temporal objects, use per-type DTF and isPlain flag + var isTemporalInput = IsTemporalObject(x); + var isPlain = isTemporalInput && x is not JsInstant; + var effectiveDtf = isTemporalInput ? GetTemporalFormatDtf(dateTimeFormat, x) : dateTimeFormat; // Get parts for both dates - var startParts = dateTimeFormat.FormatToParts(start); - var endParts = dateTimeFormat.FormatToParts(end); + var startParts = effectiveDtf.FormatToParts(start, isPlain: isPlain); + var endParts = effectiveDtf.FormatToParts(end, isPlain: isPlain); // Check if dates are practically equal (same formatted output) - var startFormatted = dateTimeFormat.Format(start); - var endFormatted = dateTimeFormat.Format(end); + var startFormatted = effectiveDtf.Format(start, isPlain: isPlain); + var endFormatted = effectiveDtf.Format(end, isPlain: isPlain); var result = new JsArray(Engine); uint index = 0; @@ -478,73 +954,45 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments // 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); + AddPartToResult(result, ref index, part.Type, part.Value, "shared"); } } 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); + // Find shared prefix and suffix at natural boundaries + var sharedPrefixEnd = FindNaturalBoundaryPrefix(startParts, endParts); + var sharedSuffixLen = FindNaturalBoundarySuffix(startParts, endParts, sharedPrefixEnd); - if (useCollapsing) + if (sharedPrefixEnd > 0 || sharedSuffixLen > 0) { - // Find shared prefix and suffix - var sharedPrefixEnd = FindSharedPrefixEnd(startParts, endParts); - var sharedSuffixStart = FindSharedSuffixStart(startParts, endParts, sharedPrefixEnd); + var startUniqueEnd = startParts.Count - sharedSuffixLen; + var endUniqueEnd = endParts.Count - sharedSuffixLen; - // Only collapse if there's a shared suffix - // If the year (last component) differs, we should output full dates - var hasSuffix = sharedSuffixStart < startParts.Count; + // Output shared prefix + for (var i = 0; i < sharedPrefixEnd; i++) + { + AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared"); + } - if (hasSuffix) + // Output start range unique part + for (var i = sharedPrefixEnd; i < startUniqueEnd; i++) { - // 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"); - } + AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "startRange"); } - else + + // Separator + AddPartToResult(result, ref index, "literal", " \u2013 ", "shared"); + + // Output end range unique part + for (var i = sharedPrefixEnd; i < endUniqueEnd; i++) { - // 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"); - } + AddPartToResult(result, ref index, endParts[i].Type, endParts[i].Value, "endRange"); + } + + // Output shared suffix + for (var i = startUniqueEnd; i < startParts.Count; i++) + { + AddPartToResult(result, ref index, startParts[i].Type, startParts[i].Value, "shared"); } } else @@ -555,7 +1003,7 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments AddPartToResult(result, ref index, part.Type, part.Value, "startRange"); } - AddPartToResult(result, ref index, "literal", " – ", "shared"); + AddPartToResult(result, ref index, "literal", " \u2013 ", "shared"); foreach (var part in endParts) { @@ -576,105 +1024,124 @@ private void AddPartToResult(JsArray result, ref uint index, string type, string 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) + /// + /// Finds the length of the shared prefix between start and end parts, + /// but only if the prefix ends at a natural boundary (e.g., the ", " literal + /// separating date from time components, or a multi-char literal separator). + /// Internal separators like "/" and ":" within date/time groups are not natural boundaries. + /// Also ensures the remaining unique part has at least 2 non-literal components to avoid + /// over-collapsing (e.g., collapsing "Mar 4, " when only year differs). + /// Returns 0 if no valid shared prefix exists. + /// + private static int FindNaturalBoundaryPrefix(List startParts, List endParts) { var minLen = System.Math.Min(startParts.Count, endParts.Count); - var i = 0; - while (i < minLen) + // Find how many parts match from the start + var matchLen = 0; + while (matchLen < minLen) { - var startPart = startParts[i]; - var endPart = endParts[i]; + var sp = startParts[matchLen]; + var ep = endParts[matchLen]; - // 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)) + if (!string.Equals(sp.Type, ep.Type, StringComparison.Ordinal) || + !string.Equals(sp.Value, ep.Value, StringComparison.Ordinal)) { break; } - i++; + matchLen++; } - // 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)) + // No shared prefix at all, or entire sequence matches (handled separately as "shared") + if (matchLen == 0 || matchLen >= minLen) { - // Check if the previous non-literal parts match - var prevNonLiteral = i - 2; - while (prevNonLiteral >= 0 && string.Equals(startParts[prevNonLiteral].Type, "literal", StringComparison.Ordinal)) - { - prevNonLiteral--; - } + return 0; + } - if (prevNonLiteral >= 0 && - string.Equals(startParts[prevNonLiteral].Value, endParts[prevNonLiteral].Value, StringComparison.Ordinal)) - { - // Include the literal in the prefix - break; - } - else + // Check if the prefix ends at a natural boundary. + var lastPart = startParts[matchLen - 1]; + if (!string.Equals(lastPart.Type, "literal", StringComparison.Ordinal)) + { + return 0; + } + + var val = lastPart.Value; + // Reject single-char internal separators + if (val.Length == 1 && (val[0] == '/' || val[0] == ':' || val[0] == '.' || val[0] == '-')) + { + return 0; + } + + // Ensure at least 2 non-literal parts remain in the unique section. + // This prevents over-collapsing like "Mar 4, 2019 – 2020" when only years differ. + var remainingNonLiteral = 0; + for (var i = matchLen; i < startParts.Count; i++) + { + if (!string.Equals(startParts[i].Type, "literal", StringComparison.Ordinal)) { - // Don't include trailing literal that precedes different values - i--; + remainingNonLiteral++; } } - return i; + if (remainingNonLiteral < 2) + { + return 0; + } + + return matchLen; } - private static int FindSharedSuffixStart(List startParts, List endParts, int prefixEnd) + /// + /// Finds the number of shared suffix parts between start and end parts, + /// starting from the given offset (to avoid overlapping with a shared prefix). + /// Only accepts suffixes that start at a natural boundary (multi-char literal). + /// Returns 0 if no valid shared suffix exists. + /// + private static int FindNaturalBoundarySuffix(List startParts, List endParts, int prefixEnd) { var startLen = startParts.Count; var endLen = endParts.Count; - if (startLen != endLen) + // Find how many parts match from the end + var matchLen = 0; + while (matchLen < startLen - prefixEnd && matchLen < endLen - prefixEnd) { - return startLen; - } + var sp = startParts[startLen - 1 - matchLen]; + var ep = endParts[endLen - 1 - matchLen]; - var i = startLen - 1; + if (!string.Equals(sp.Type, ep.Type, StringComparison.Ordinal) || + !string.Equals(sp.Value, ep.Value, StringComparison.Ordinal)) + { + break; + } + + matchLen++; + } - while (i >= prefixEnd) + if (matchLen == 0) { - var startPart = startParts[i]; - var endPart = endParts[i]; + return 0; + } - // 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)) + // Check if the suffix starts at a natural boundary + // The first suffix part (counting from the end) should be preceded by a natural boundary literal + var firstSuffixIdx = startLen - matchLen; + var boundaryPart = startParts[firstSuffixIdx]; + if (string.Equals(boundaryPart.Type, "literal", StringComparison.Ordinal)) + { + var val = boundaryPart.Value; + // Reject single-char internal separators + if (val.Length == 1 && (val[0] == '/' || val[0] == ':' || val[0] == '.' || val[0] == '-')) { - break; + return 0; } - i--; + // Accept multi-char literals like ", " as natural boundaries + return matchLen; } - // Move past the last differing part to get suffix start - return i + 1; + return 0; } private DateTime ToDateTimeForRange(JsValue value) diff --git a/Jint/Native/Intl/DefaultCldrProvider.cs b/Jint/Native/Intl/DefaultCldrProvider.cs index 051cad1878..1a002ae50a 100644 --- a/Jint/Native/Intl/DefaultCldrProvider.cs +++ b/Jint/Native/Intl/DefaultCldrProvider.cs @@ -317,7 +317,7 @@ private DefaultCldrProvider() return null; } - public string[]? GetMonthNames(string locale, string style) + public string[]? GetMonthNames(string locale, string style, string? calendar) { try { @@ -355,7 +355,7 @@ private DefaultCldrProvider() } } - public string[]? GetDayPeriods(string locale, string style) + public string[]? GetDayPeriods(string locale, string style, string? calendar) { try { @@ -368,7 +368,7 @@ private DefaultCldrProvider() } } - public string[]? GetEraNames(string locale, string style) + public string[]? GetEraNames(string locale, string style, string? calendar) { if (!IsEnglish(locale)) { @@ -386,67 +386,6 @@ private DefaultCldrProvider() // === Display Names === - public string? GetLanguageDisplayName(string locale, string code) - { - if (!IsEnglish(locale)) - { - return null; - } - - try - { - var culture = new CultureInfo(code); - return culture.EnglishName; - } - catch - { - return null; - } - } - - public string? GetRegionDisplayName(string locale, string code) - { - if (!IsEnglish(locale)) - { - return null; - } - - try - { - var region = new RegionInfo(code); - return region.EnglishName; - } - catch - { - return null; - } - } - - public string? GetScriptDisplayName(string locale, string code) - { - if (!IsEnglish(locale)) - { - return null; - } - - // Common script names - return code.ToUpperInvariant() switch - { - "LATN" => "Latin", - "CYRL" => "Cyrillic", - "ARAB" => "Arabic", - "HANS" => "Simplified Chinese", - "HANT" => "Traditional Chinese", - "DEVA" => "Devanagari", - "GREK" => "Greek", - "HEBR" => "Hebrew", - "JPAN" => "Japanese", - "KORE" => "Korean", - "THAI" => "Thai", - _ => null - }; - } - public string? GetCurrencyDisplayName(string locale, string code) { if (!IsEnglish(locale)) diff --git a/Jint/Native/Intl/DurationFormatPrototype.cs b/Jint/Native/Intl/DurationFormatPrototype.cs index 6ba1659d18..ba51847615 100644 --- a/Jint/Native/Intl/DurationFormatPrototype.cs +++ b/Jint/Native/Intl/DurationFormatPrototype.cs @@ -1,6 +1,7 @@ using System.Numerics; using Jint.Native.Object; using Jint.Native.Symbol; +using Jint.Native.Temporal; using Jint.Runtime; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; @@ -135,8 +136,30 @@ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value) // Per spec: if input is a string, try to parse it as a duration if (value.IsString()) { - // String durations not yet supported - throw RangeError - Throw.RangeError(_realm, "Duration string parsing is not supported"); + var parsed = TemporalHelpers.ParseDuration(value.ToString()); + if (parsed is null) + { + Throw.RangeError(_realm, "Invalid duration string"); + return default; + } + + var dr = parsed.Value; + var record = new JsDurationFormat.DurationRecord + { + Years = dr.Years, + Months = dr.Months, + Weeks = dr.Weeks, + Days = dr.Days, + Hours = dr.Hours, + Minutes = dr.Minutes, + Seconds = dr.Seconds, + Milliseconds = dr.Milliseconds, + Microseconds = dr.Microseconds, + Nanoseconds = dr.Nanoseconds, + }; + + ValidateDurationRecord(record); + return record; } if (!value.IsObject()) @@ -146,6 +169,28 @@ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value) var obj = value.AsObject(); + // Fast path for Temporal.Duration objects - read internal slots directly + if (obj is JsDuration jsDuration) + { + var dr = jsDuration.DurationRecord; + var record = new JsDurationFormat.DurationRecord + { + Years = dr.Years, + Months = dr.Months, + Weeks = dr.Weeks, + Days = dr.Days, + Hours = dr.Hours, + Minutes = dr.Minutes, + Seconds = dr.Seconds, + Milliseconds = dr.Milliseconds, + Microseconds = dr.Microseconds, + Nanoseconds = dr.Nanoseconds, + }; + + ValidateDurationRecord(record); + return record; + } + // Check if at least one duration property is defined and not undefined var hasDefinedProperty = false; foreach (var prop in DurationProperties) @@ -163,23 +208,25 @@ private JsDurationFormat.DurationRecord ToDurationRecord(JsValue value) Throw.TypeError(_realm, "Duration must have at least one duration property defined"); } - var record = new JsDurationFormat.DurationRecord(); - - record.Years = GetDurationComponent(obj, "years"); - record.Months = GetDurationComponent(obj, "months"); - record.Weeks = GetDurationComponent(obj, "weeks"); - record.Days = GetDurationComponent(obj, "days"); - record.Hours = GetDurationComponent(obj, "hours"); - record.Minutes = GetDurationComponent(obj, "minutes"); - record.Seconds = GetDurationComponent(obj, "seconds"); - record.Milliseconds = GetDurationComponent(obj, "milliseconds"); - record.Microseconds = GetDurationComponent(obj, "microseconds"); - record.Nanoseconds = GetDurationComponent(obj, "nanoseconds"); - - // Validate the duration record per spec (IsValidDurationRecord) - ValidateDurationRecord(record); - - return record; + { + var record = new JsDurationFormat.DurationRecord(); + + record.Years = GetDurationComponent(obj, "years"); + record.Months = GetDurationComponent(obj, "months"); + record.Weeks = GetDurationComponent(obj, "weeks"); + record.Days = GetDurationComponent(obj, "days"); + record.Hours = GetDurationComponent(obj, "hours"); + record.Minutes = GetDurationComponent(obj, "minutes"); + record.Seconds = GetDurationComponent(obj, "seconds"); + record.Milliseconds = GetDurationComponent(obj, "milliseconds"); + record.Microseconds = GetDurationComponent(obj, "microseconds"); + record.Nanoseconds = GetDurationComponent(obj, "nanoseconds"); + + // Validate the duration record per spec (IsValidDurationRecord) + ValidateDurationRecord(record); + + return record; + } } private void ValidateDurationRecord(JsDurationFormat.DurationRecord record) diff --git a/Jint/Native/Intl/ICldrProvider.cs b/Jint/Native/Intl/ICldrProvider.cs index 4276c14e2e..9927a1f423 100644 --- a/Jint/Native/Intl/ICldrProvider.cs +++ b/Jint/Native/Intl/ICldrProvider.cs @@ -89,8 +89,9 @@ public interface ICldrProvider /// /// The locale identifier. /// Style: "long", "short", "narrow", or "numeric". + /// Calendar identifier (e.g., "gregory", "buddhist"), or null for default. /// Array of 12 month names (January-December) or null if not available. - string[]? GetMonthNames(string locale, string style); + string[]? GetMonthNames(string locale, string style, string? calendar); /// /// Gets weekday names for a locale. @@ -105,43 +106,21 @@ public interface ICldrProvider /// /// The locale identifier. /// Style: "long", "short", or "narrow". + /// Calendar identifier (e.g., "gregory", "buddhist"), or null for default. /// Array of day period names or null if not available. - string[]? GetDayPeriods(string locale, string style); + string[]? GetDayPeriods(string locale, string style, string? calendar); /// /// Gets era names for a locale. /// /// The locale identifier. /// Style: "long", "short", or "narrow". + /// Calendar identifier (e.g., "gregory", "japanese"), or null for default. /// Array of era names or null if not available. - string[]? GetEraNames(string locale, string style); + string[]? GetEraNames(string locale, string style, string? calendar); // === Display Names === - /// - /// Gets the display name for a language code. - /// - /// The locale for localization. - /// The language code to get the name for. - /// Display name or null if not available. - string? GetLanguageDisplayName(string locale, string code); - - /// - /// Gets the display name for a region code. - /// - /// The locale for localization. - /// The region code to get the name for. - /// Display name or null if not available. - string? GetRegionDisplayName(string locale, string code); - - /// - /// Gets the display name for a script code. - /// - /// The locale for localization. - /// The script code to get the name for. - /// Display name or null if not available. - string? GetScriptDisplayName(string locale, string code); - /// /// Gets the display name for a currency code. /// diff --git a/Jint/Native/Intl/JsDateTimeFormat.cs b/Jint/Native/Intl/JsDateTimeFormat.cs index 6499e20172..ff5fae224c 100644 --- a/Jint/Native/Intl/JsDateTimeFormat.cs +++ b/Jint/Native/Intl/JsDateTimeFormat.cs @@ -31,6 +31,7 @@ internal JsDateTimeFormat( string? second, int? fractionalSecondDigits, string? timeZoneName, + bool hasExplicitFormatComponents, DateTimeFormatInfo dateTimeFormatInfo, CultureInfo cultureInfo) : base(engine) { @@ -53,6 +54,7 @@ internal JsDateTimeFormat( Second = second; FractionalSecondDigits = fractionalSecondDigits; TimeZoneName = timeZoneName; + HasExplicitFormatComponents = hasExplicitFormatComponents; DateTimeFormatInfo = dateTimeFormatInfo; CultureInfo = cultureInfo; } @@ -75,6 +77,7 @@ internal JsDateTimeFormat( internal string? Second { get; } internal int? FractionalSecondDigits { get; } internal string? TimeZoneName { get; } + internal bool HasExplicitFormatComponents { get; } internal DateTimeFormatInfo DateTimeFormatInfo { get; } internal CultureInfo CultureInfo { get; } @@ -88,7 +91,8 @@ internal JsDateTimeFormat( /// /// The .NET DateTime to format /// Optional original JavaScript year (for dates outside .NET DateTime range) - internal string Format(DateTime dateTime, int? originalYear = null) + /// If true, skip timezone conversion (for plain Temporal types) + internal string Format(DateTime dateTime, int? originalYear = null, bool isPlain = false) { // For Chinese and Dangi calendars, use FormatToParts to get consistent output // This ensures the special part types (relatedYear, yearName) are properly handled @@ -101,7 +105,7 @@ internal string Format(DateTime dateTime, int? originalYear = null) if (isLunisolarCalendar || hasEra) { - var parts = FormatToParts(dateTime, originalYear); + var parts = FormatToParts(dateTime, originalYear, isPlain); var sb = new StringBuilder(); foreach (var part in parts) { @@ -111,9 +115,21 @@ internal string Format(DateTime dateTime, int? originalYear = null) } // Convert to specified timezone if one was provided - if (TimeZone != null) + // For plain Temporal types (isPlain=true), skip timezone conversion since + // they represent wall-clock time, not an absolute point in time + if (!isPlain) { - dateTime = ConvertToTimeZone(dateTime, TimeZone); + if (TimeZone != null) + { + dateTime = ConvertToTimeZone(dateTime, TimeZone); + } + else if (dateTime.Kind == DateTimeKind.Utc) + { + // No explicit timezone: convert UTC to engine's default timezone + // (not system ToLocalTime which ignores engine's configured timezone) + var defaultTz = _engine.Options.TimeSystem.DefaultTimeZone; + dateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, defaultTz); + } } string result; @@ -121,7 +137,7 @@ internal string Format(DateTime dateTime, int? originalYear = null) // If dateStyle or timeStyle is specified, use those if (DateStyle != null || TimeStyle != null) { - result = FormatWithStyles(dateTime); + result = FormatWithStyles(dateTime, isPlain); } else { @@ -240,7 +256,7 @@ private static DateTime ConvertToTimeZone(DateTime dateTime, string timeZoneId) } // Get era names from CLDR provider if available - var eraNames = CldrProvider.GetEraNames(Locale, style); + var eraNames = CldrProvider.GetEraNames(Locale, style, calendar); // Use original year for era calculation if the date was clamped var effectiveYear = originalYear ?? dateTime.Year; @@ -635,14 +651,14 @@ private void AddYearPart(DateTime dateTime, List result, ref bool hasDate = true; } - private string FormatWithStyles(DateTime dateTime) + private string FormatWithStyles(DateTime dateTime, bool isPlain = false) { // 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); + var timePart = FormatTimeStyle(dateTime, isPlain); return $"{datePart}, {timePart}"; } @@ -653,7 +669,7 @@ private string FormatWithStyles(DateTime dateTime) if (TimeStyle != null) { - return FormatTimeStyle(dateTime); + return FormatTimeStyle(dateTime, isPlain); } return dateTime.ToString("G", CultureInfo); @@ -738,60 +754,37 @@ private string FormatShortDate(DateTime dateTime) /// /// Formats time using timeStyle, respecting hourCycle /// - private string FormatTimeStyle(DateTime dateTime) + private string FormatTimeStyle(DateTime dateTime, bool isPlain = false) { - // 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); - } + // Use ComputeHourValue to handle all hour cycles correctly + // Style-based formatting always pads 24-hour values (e.g., "05:00:00" not "5:00:00") + ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hour, padByDefault: true); + // Plain Temporal types should not show timeZoneName var timeZoneSuffix = ""; - if (string.Equals(TimeStyle, "full", StringComparison.Ordinal)) + if (!isPlain) { - if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase)) - { - timeZoneSuffix = " Coordinated Universal Time"; - } - else + if (string.Equals(TimeStyle, "full", StringComparison.Ordinal)) { - timeZoneSuffix = " " + TimeZoneInfo.Local.DisplayName; + timeZoneSuffix = " " + GetTimeZoneDisplayName(dateTime, longName: true, generic: false); } - } - else if (string.Equals(TimeStyle, "long", StringComparison.Ordinal)) - { - if (!string.IsNullOrEmpty(TimeZone) && string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(TimeStyle, "long", StringComparison.Ordinal)) { - timeZoneSuffix = " UTC"; + timeZoneSuffix = " " + GetTimeZoneDisplayName(dateTime, longName: false, generic: false); } } + var minuteStr = dateTime.Minute.ToString("D2", CultureInfo.InvariantCulture); + var secondStr = dateTime.Second.ToString("D2", CultureInfo.InvariantCulture); + var dayPeriod = use12Hour ? " " + GetDayPeriod(dateTime.Hour) : ""; + 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), + "full" => hourStr + ":" + minuteStr + ":" + secondStr + dayPeriod + timeZoneSuffix, + "long" => hourStr + ":" + minuteStr + ":" + secondStr + dayPeriod + timeZoneSuffix, + "medium" => hourStr + ":" + minuteStr + ":" + secondStr + dayPeriod, + "short" => hourStr + ":" + minuteStr + dayPeriod, + _ => hourStr + ":" + minuteStr + dayPeriod, }; } @@ -862,16 +855,14 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null) } } - // Hour + // Hour - use pre-computed value to handle all hour cycles (h11/h12/h23/h24) + bool hourUse12Hour = false; 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" - }); + ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hr); + hourUse12Hour = use12Hr; + // Use escaped literal in format string so .NET outputs our pre-computed value + parts.Add("'" + hourStr + "'"); } // Minute - for time components, "numeric" typically uses 2-digit padding in most locales @@ -904,25 +895,17 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null) // 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; + var needsAmPm = Hour != null && hourUse12Hour && DayPeriod == null; if (needsAmPm) { parts.Add("tt"); } - // Time zone name + // Time zone name - compute the display name directly (not via .NET format specifier) + string? timeZoneNameStr = null; if (TimeZoneName != null) { - parts.Add(TimeZoneName switch - { - "long" => "zzz", - "short" => "zzz", - "shortOffset" => "zzz", - "longOffset" => "zzz", - "shortGeneric" => "zzz", - "longGeneric" => "zzz", - _ => "zzz" - }); + timeZoneNameStr = GetFormattedTimeZoneName(dateTime); } // Handle DayPeriod option (extended day periods like "in the morning") @@ -959,10 +942,15 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null) } } - return formatted + " " + GetExtendedDayPeriod(dateTime.Hour); + var dayPeriodResult = formatted + " " + GetExtendedDayPeriod(dateTime.Hour); + if (timeZoneNameStr != null) + { + dayPeriodResult += " " + timeZoneNameStr; + } + return dayPeriodResult; } - if (parts.Count == 0 && eraValue == null) + if (parts.Count == 0 && eraValue == null && timeZoneNameStr == null) { // Default format if no components specified return dateTime.ToString("G", CultureInfo); @@ -993,6 +981,19 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null) } } + // Append timezone name if specified + if (timeZoneNameStr != null) + { + if (result.Length > 0) + { + result += " " + timeZoneNameStr; + } + else + { + result = timeZoneNameStr; + } + } + return result; } @@ -1019,6 +1020,77 @@ private string GetHourFormat() return timePattern.Contains('H') ? "h24" : "h12"; } + /// + /// Computes the formatted hour string based on the hourCycle, hour option, and actual hour value. + /// Returns the formatted hour string and whether AM/PM should be shown. + /// Per ECMA-402: h11=0-11 (12hr), h12=1-12 (12hr), h23=0-23 (24hr), h24=1-24 (24hr). + /// 24-hour formats always pad to 2 digits; 12-hour formats pad only for "2-digit" option. + /// + /// + /// Computes the formatted hour value based on HourCycle and locale defaults. + /// + /// The 0-23 hour value + /// Output: formatted hour string + /// Output: whether 12-hour format is used (needs AM/PM) + /// If true, always pad h23/h24 hours (used by style-based formatting) + private void ComputeHourValue(int hour, out string hourStr, out bool use12Hour, bool padByDefault = false) + { + int hourValue; + + if (string.Equals(HourCycle, "h11", StringComparison.Ordinal)) + { + hourValue = hour % 12; // 0-11 + use12Hour = true; + } + else if (string.Equals(HourCycle, "h24", StringComparison.Ordinal)) + { + hourValue = hour == 0 ? 24 : hour; // 1-24 + use12Hour = false; + } + else if (string.Equals(HourCycle, "h23", StringComparison.Ordinal)) + { + hourValue = hour; // 0-23 + use12Hour = false; + } + else if (string.Equals(HourCycle, "h12", StringComparison.Ordinal)) + { + hourValue = hour % 12 == 0 ? 12 : hour % 12; // 1-12 + use12Hour = true; + } + else + { + // No explicit hourCycle - derive from locale using CLDR defaults + // (not from .NET CultureInfo which may reflect system user overrides) + var defaultHc = DateTimeFormatPrototype.GetDefaultHourCycle(Locale); + if (string.Equals(defaultHc, "h11", StringComparison.Ordinal)) + { + hourValue = hour % 12; // 0-11 + use12Hour = true; + } + else if (string.Equals(defaultHc, "h23", StringComparison.Ordinal)) + { + hourValue = hour; // 0-23 + use12Hour = false; + } + else if (string.Equals(defaultHc, "h24", StringComparison.Ordinal)) + { + hourValue = hour == 0 ? 24 : hour; // 1-24 + use12Hour = false; + } + else + { + // h12 default + hourValue = hour % 12 == 0 ? 12 : hour % 12; // 1-12 + use12Hour = true; + } + } + + // Per ECMA-402: 24-hour formats (h23, h24) always pad to 2 digits. + // 12-hour formats only pad when Hour option is "2-digit". + var pad = !use12Hour || string.Equals(Hour, "2-digit", StringComparison.Ordinal); + hourStr = pad ? hourValue.ToString("D2", CultureInfo.InvariantCulture) : hourValue.ToString(CultureInfo.InvariantCulture); + } + private string BuildFormatString(List parts) { // Simple join - a more sophisticated implementation would use @@ -1038,21 +1110,23 @@ private string BuildFormatString(List parts) } var firstChar = part[0]; + // Escaped literals starting with ' are pre-computed hour values (treated as time component) + var isHourLiteral = firstChar == '\''; 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 (firstChar is 'h' or 'H' or 'm' or 's' or 'f' or 't' || isHourLiteral) { if (!hasTime) { if (hasDate) { - result.Append(' '); + result.Append("', '"); // Literal ", " between date and time } hasTime = true; } - else if (firstChar is not 't' and not 'f') + else if (firstChar is not 't' and not 'f' and not '\'') { result.Append(':'); } @@ -1102,7 +1176,7 @@ private string BuildFormatString(List parts) } else { - if (firstChar is 'h' or 'H' or 'm' or 's' or 'f') + if (firstChar is 'h' or 'H' or 'm' or 's' or 'f' || isHourLiteral) { hasTime = true; } @@ -1132,12 +1206,23 @@ private string BuildFormatString(List parts) /// /// The .NET DateTime to format /// Optional original JavaScript year (for dates outside .NET DateTime range) - internal List FormatToParts(DateTime dateTime, int? originalYear = null) + /// If true, skip timezone conversion (for plain Temporal types) + internal List FormatToParts(DateTime dateTime, int? originalYear = null, bool isPlain = false) { // Convert to specified timezone if one was provided - if (TimeZone != null) + // For plain Temporal types (isPlain=true), skip timezone conversion + if (!isPlain) { - dateTime = ConvertToTimeZone(dateTime, TimeZone); + if (TimeZone != null) + { + dateTime = ConvertToTimeZone(dateTime, TimeZone); + } + else if (dateTime.Kind == DateTimeKind.Utc) + { + // No explicit timezone: convert UTC to engine's default timezone + var defaultTz = _engine.Options.TimeSystem.DefaultTimeZone; + dateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, defaultTz); + } } var result = new List(); @@ -1145,7 +1230,7 @@ internal List FormatToParts(DateTime dateTime, int? originalYear = if (DateStyle != null || TimeStyle != null) { // For style-based formatting, use a simpler approach - FormatStyleToParts(dateTime, result, originalYear); + FormatStyleToParts(dateTime, result, originalYear, isPlain); } else { @@ -1169,7 +1254,7 @@ internal List FormatToParts(DateTime dateTime, int? originalYear = return result; } - private void FormatStyleToParts(DateTime dateTime, List result, int? originalYear) + private void FormatStyleToParts(DateTime dateTime, List result, int? originalYear, bool isPlain = false) { // For style-based formatting, decompose into proper parts // Map styles to component options and use component-based parts generation @@ -1189,7 +1274,7 @@ private void FormatStyleToParts(DateTime dateTime, List result, in if (hasTime) { - FormatTimeStyleToParts(dateTime, result); + FormatTimeStyleToParts(dateTime, result, isPlain); } } @@ -1304,14 +1389,13 @@ private void AddLunisolarDateParts(List result, ChineseCalendarHel } } - private void FormatTimeStyleToParts(DateTime dateTime, List result) + private void FormatTimeStyleToParts(DateTime dateTime, List result, bool isPlain = false) { var style = TimeStyle; - var use12Hour = IsUsing12HourFormat(); - var hour = use12Hour ? (dateTime.Hour % 12 == 0 ? 12 : dateTime.Hour % 12) : dateTime.Hour; + ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hour, padByDefault: true); // Hour - result.Add(new DateTimePart("hour", hour.ToString(CultureInfo))); + result.Add(new DateTimePart("hour", hourStr)); // Minute (always for time styles) result.Add(new DateTimePart("literal", ":")); @@ -1331,46 +1415,132 @@ private void FormatTimeStyleToParts(DateTime dateTime, List result result.Add(new DateTimePart("dayPeriod", dateTime.Hour < 12 ? "AM" : "PM")); } - // Time zone name (for long and full) - if (string.Equals(style, "full", StringComparison.Ordinal)) + // Time zone name (for long and full) - omit for plain Temporal types + if (!isPlain) { - result.Add(new DateTimePart("literal", " ")); - result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(true))); + if (string.Equals(style, "full", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(dateTime, longName: true, generic: false))); + } + else if (string.Equals(style, "long", StringComparison.Ordinal)) + { + result.Add(new DateTimePart("literal", " ")); + result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(dateTime, longName: false, generic: false))); + } } - else if (string.Equals(style, "long", StringComparison.Ordinal)) + } + + private string GetTimeZoneDisplayName(DateTime utcDateTime, bool longName, bool generic) + { + if (TimeZone != null) { - result.Add(new DateTimePart("literal", " ")); - result.Add(new DateTimePart("timeZoneName", GetTimeZoneDisplayName(false))); + if (string.Equals(TimeZone, "UTC", StringComparison.OrdinalIgnoreCase)) + { + return longName ? "Coordinated Universal Time" : "UTC"; + } + + // Handle offset timezone format like "+00:00", "+03:00", "-07:30" + var offset = TryParseOffset(TimeZone); + if (offset.HasValue) + { + return FormatGmtOffset(offset.Value, longName); + } + + // Try CLDR metazone data first (provides locale-correct names) + var isDst = false; + try + { + var tzInfo = TimeZoneInfo.FindSystemTimeZoneById(TimeZone); + isDst = tzInfo.IsDaylightSavingTime(DateTime.SpecifyKind(utcDateTime, DateTimeKind.Utc)); + } + catch + { + // If timezone not found, isDst stays false + } + + var cldrName = Data.MetaZoneData.GetDisplayName(TimeZone, isDst, longName, generic); + if (cldrName != null) + { + return cldrName; + } + + // Fallback to .NET TimeZoneInfo names + try + { + var tzInfo = TimeZoneInfo.FindSystemTimeZoneById(TimeZone); + if (longName) + { + return isDst ? tzInfo.DaylightName : tzInfo.StandardName; + } + var parts = TimeZone.Split('/'); + return parts[parts.Length - 1].Replace('_', ' '); + } + catch + { + var parts = TimeZone.Split('/'); + return longName ? TimeZone : parts[parts.Length - 1]; + } } + return longName ? TimeZoneInfo.Local.StandardName : TimeZoneInfo.Local.Id; } - private bool IsUsing12HourFormat() + /// + /// Formats a GMT offset display name. Short: "GMT+1", Long: "GMT+01:00". + /// + private static string FormatGmtOffset(TimeSpan offset, bool longName) { - // Check hourCycle first - if (HourCycle != null) + if (offset == TimeSpan.Zero) { - return string.Equals(HourCycle, "h11", StringComparison.Ordinal) || - string.Equals(HourCycle, "h12", StringComparison.Ordinal); + return "GMT"; } - // 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); + var sign = offset < TimeSpan.Zero ? "-" : "+"; + var absOffset = offset < TimeSpan.Zero ? offset.Negate() : offset; + + if (longName) + { + return $"GMT{sign}{absOffset.Hours:D2}:{absOffset.Minutes:D2}"; + } + + if (absOffset.Minutes == 0) + { + return $"GMT{sign}{absOffset.Hours}"; + } + + return $"GMT{sign}{absOffset.Hours}:{absOffset.Minutes:D2}"; } - private string GetTimeZoneDisplayName(bool longName) + /// + /// Gets the formatted timezone name based on the TimeZoneName option. + /// + private string GetFormattedTimeZoneName(DateTime dateTime) { - if (TimeZone != null) + if (string.Equals(TimeZoneName, "long", StringComparison.Ordinal)) { - 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 GetTimeZoneDisplayName(dateTime, longName: true, generic: false); + } + if (string.Equals(TimeZoneName, "longGeneric", StringComparison.Ordinal)) + { + return GetTimeZoneDisplayName(dateTime, longName: true, generic: true); + } + if (string.Equals(TimeZoneName, "short", StringComparison.Ordinal)) + { + return GetTimeZoneDisplayName(dateTime, longName: false, generic: false); + } + if (string.Equals(TimeZoneName, "shortGeneric", StringComparison.Ordinal)) + { + return GetTimeZoneDisplayName(dateTime, longName: false, generic: true); + } + if (string.Equals(TimeZoneName, "longOffset", StringComparison.Ordinal)) + { + return "GMT" + dateTime.ToString("zzz", CultureInfo); + } + if (string.Equals(TimeZoneName, "shortOffset", StringComparison.Ordinal)) + { + return "GMT" + dateTime.ToString("zzz", CultureInfo); } - return longName ? TimeZoneInfo.Local.DisplayName : TimeZoneInfo.Local.Id; + return GetTimeZoneDisplayName(dateTime, longName: false, generic: false); } private void FormatComponentsToParts(DateTime dateTime, List result, int? originalYear = null) @@ -1443,21 +1613,17 @@ private void FormatComponentsToParts(DateTime dateTime, List resul } } - // Hour + // Hour - use pre-computed value to handle all hour cycles (h11/h12/h23/h24) + bool hourUse12Hour = false; 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))); + ComputeHourValue(dateTime.Hour, out var hourStr, out var use12Hr); + hourUse12Hour = use12Hr; + result.Add(new DateTimePart("hour", hourStr)); hasTime = true; } @@ -1508,7 +1674,7 @@ private void FormatComponentsToParts(DateTime dateTime, List resul } result.Add(new DateTimePart("dayPeriod", GetExtendedDayPeriod(dateTime.Hour))); } - else if (Hour != null && string.Equals(GetHourFormat(), "h12", StringComparison.Ordinal)) + else if (Hour != null && hourUse12Hour) { result.Add(new DateTimePart("literal", " ")); result.Add(new DateTimePart("dayPeriod", dateTime.ToString("tt", CultureInfo))); @@ -1518,7 +1684,7 @@ private void FormatComponentsToParts(DateTime dateTime, List resul if (TimeZoneName != null) { result.Add(new DateTimePart("literal", " ")); - result.Add(new DateTimePart("timeZoneName", dateTime.ToString("zzz", CultureInfo))); + result.Add(new DateTimePart("timeZoneName", GetFormattedTimeZoneName(dateTime))); } // If no parts were added, use default format diff --git a/Jint/Native/Temporal/DefaultTimeZoneProvider.cs b/Jint/Native/Temporal/DefaultTimeZoneProvider.cs index 0a0fb15b9f..995b69c1aa 100644 --- a/Jint/Native/Temporal/DefaultTimeZoneProvider.cs +++ b/Jint/Native/Temporal/DefaultTimeZoneProvider.cs @@ -59,6 +59,14 @@ private static bool IsWindowsPlatform() ["Europe/Dublin"] = "GMT Standard Time", ["Europe/Paris"] = "Romance Standard Time", ["CET"] = "Romance Standard Time", // IANA backward compatibility link + ["EET"] = "FLE Standard Time", // Eastern European Time (UTC+2) + ["MET"] = "Romance Standard Time", // Middle European Time (UTC+1) + ["WET"] = "GMT Standard Time", // Western European Time (UTC+0) + ["America/Ciudad_Juarez"] = "US Mountain Standard Time", // Added in TZDB 2022g + ["Antarctica/Troll"] = "W. Europe Standard Time", // UTC+0/+2 (approximation) + ["Antarctica/Vostok"] = "Qyzylorda Standard Time", // UTC+5 (since Dec 2023) + ["Asia/Urumqi"] = "Central Asia Standard Time", // UTC+6 + ["Asia/Kashgar"] = "Central Asia Standard Time", // Link to Asia/Urumqi ["Europe/Berlin"] = "W. Europe Standard Time", ["Europe/Rome"] = "W. Europe Standard Time", ["Europe/Madrid"] = "Romance Standard Time", @@ -112,6 +120,10 @@ private static bool IsWindowsPlatform() ["GMT"] = "UTC", }; + // Case-insensitive lookup mapping IANA IDs to their canonical casing. + // Built from IanaToWindows keys at static init time. + private static readonly Dictionary IanaCanonicalCasing = BuildCanonicalCasingDict(); + /// /// Singleton instance of the default provider. /// @@ -140,13 +152,16 @@ public long GetOffsetNanosecondsFor(string timeZoneId, BigInteger epochNanosecon throw new ArgumentException($"Unknown time zone: {timeZoneId}", nameof(timeZoneId)); } - var epochTicks = (long) (epochNanoseconds / NanosecondsPerTick) + UnixEpochTicks; + var bigTicks = epochNanoseconds / NanosecondsPerTick + UnixEpochTicks; - // Clamp to valid .NET DateTime range - if (epochTicks < DateTime.MinValue.Ticks) + // Clamp to valid .NET DateTime range (using BigInteger comparison to avoid overflow) + long epochTicks; + if (bigTicks < DateTime.MinValue.Ticks) epochTicks = DateTime.MinValue.Ticks; - if (epochTicks > DateTime.MaxValue.Ticks) + else if (bigTicks > DateTime.MaxValue.Ticks) epochTicks = DateTime.MaxValue.Ticks; + else + epochTicks = (long) bigTicks; var utcDateTime = new DateTime(epochTicks, DateTimeKind.Utc); var offset = tz.GetUtcOffset(utcDateTime); @@ -216,8 +231,8 @@ public BigInteger[] GetPossibleInstantsFor( year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offsets[i]); } - // Sort by offset (earlier offset = later instant) - System.Array.Sort(results, (a, b) => b.CompareTo(a)); + // Sort ascending (earliest epoch nanosecond first) per spec + System.Array.Sort(results); return results; } @@ -257,13 +272,13 @@ public BigInteger[] GetPossibleInstantsFor( return null; } - var epochTicks = (long) (epochNanoseconds / NanosecondsPerTick) + UnixEpochTicks; - if (epochTicks < DateTime.MinValue.Ticks || epochTicks > DateTime.MaxValue.Ticks) + var bigTicksNext = epochNanoseconds / NanosecondsPerTick + UnixEpochTicks; + if (bigTicksNext < DateTime.MinValue.Ticks || bigTicksNext > DateTime.MaxValue.Ticks) { return null; } - var currentUtc = new DateTime(epochTicks, DateTimeKind.Utc); + var currentUtc = new DateTime((long) bigTicksNext, DateTimeKind.Utc); // Find the next transition - this is an approximation foreach (var rule in adjustmentRules) @@ -325,13 +340,13 @@ public BigInteger[] GetPossibleInstantsFor( return null; } - var epochTicks = (long) (epochNanoseconds / NanosecondsPerTick) + UnixEpochTicks; - if (epochTicks < DateTime.MinValue.Ticks || epochTicks > DateTime.MaxValue.Ticks) + var bigTicksPrev = epochNanoseconds / NanosecondsPerTick + UnixEpochTicks; + if (bigTicksPrev < DateTime.MinValue.Ticks || bigTicksPrev > DateTime.MaxValue.Ticks) { return null; } - var currentUtc = new DateTime(epochTicks, DateTimeKind.Utc); + var currentUtc = new DateTime((long) bigTicksPrev, DateTimeKind.Utc); BigInteger? lastTransition = null; foreach (var rule in adjustmentRules) @@ -424,15 +439,62 @@ public bool IsValidTimeZone(string timeZoneId) if (string.IsNullOrEmpty(timeZoneId)) return null; - // Handle UTC variants - if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase) || - timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase) || - timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase) || - timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase)) + // Handle UTC variants - preserve the original identifier per spec + // TimeZoneEquals handles comparing different UTC names + if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) { return "UTC"; } + if (timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase)) + { + return "Etc/UTC"; + } + + if (timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase)) + { + return "Etc/GMT"; + } + + if (timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase)) + { + return "GMT"; + } + + // Handle Etc/GMT+N and Etc/GMT-N timezone identifiers (case-insensitive) + // These are IANA names, not offset strings. Valid: Etc/GMT-0..Etc/GMT-14, Etc/GMT+0..Etc/GMT+12 + // Handle Etc/GMT0 (IANA Link name, no sign) + if (timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase)) + { + return "Etc/GMT0"; + } + + // Invalid: zero-padded like Etc/GMT-01, Etc/GMT+00, out of range like Etc/GMT-24 + if (timeZoneId.StartsWith("Etc/GMT", StringComparison.OrdinalIgnoreCase) && timeZoneId.Length > 7) + { + var suffix = timeZoneId.Substring(7); + if ((suffix[0] == '+' || suffix[0] == '-') && suffix.Length >= 2 && suffix.Length <= 3) + { + // Reject zero-padded: if 2 digits and first is 0 (e.g., +01, -09, +00) + if (suffix.Length == 3 && suffix[1] == '0') + { + return null; + } + + if (int.TryParse(suffix.AsSpan(1), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var offset)) + { + var maxOffset = suffix[0] == '-' ? 14 : 12; + if (offset >= 0 && offset <= maxOffset) + { + return $"Etc/GMT{suffix}"; + } + } + } + + return null; + } + // Handle offset strings // Valid formats: +HH (3 chars), +HH:MM (6 chars), or +HHMM (5 chars) - no seconds allowed if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-')) @@ -490,10 +552,10 @@ public bool IsValidTimeZone(string timeZoneId) } // On Windows, try to find IANA ID - // First check if input was already IANA - if (IanaToWindows.ContainsKey(timeZoneId)) + // First check if input was already IANA (case-insensitive match) + if (IanaCanonicalCasing.TryGetValue(timeZoneId, out var canonicalIana)) { - return timeZoneId; + return canonicalIana; } // Try reverse lookup @@ -557,6 +619,63 @@ public string GetDefaultTimeZone() return local.Id; } + /// + /// Gets the primary IANA identifier for a timezone, resolving aliases. + /// E.g., "Asia/Calcutta" → "Asia/Kolkata", "America/Atka" → "America/Adak". + /// + public string? GetPrimaryTimeZoneIdentifier(string timeZoneId) + { + if (string.IsNullOrEmpty(timeZoneId)) + { + return null; + } + + // UTC variants all map to "UTC" + if (timeZoneId.Equals("UTC", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/UTC", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/UCT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT+0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/GMT-0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/Greenwich", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/Universal", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Etc/Zulu", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("UCT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT+0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT-0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("GMT0", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Greenwich", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Universal", StringComparison.OrdinalIgnoreCase) || + timeZoneId.Equals("Zulu", StringComparison.OrdinalIgnoreCase)) + { + return "UTC"; + } + + // Offset strings are their own primary identifier + if (timeZoneId.Length >= 3 && (timeZoneId[0] == '+' || timeZoneId[0] == '-')) + { + return CanonicalizeTimeZone(timeZoneId); + } + +#if NET6_0_OR_GREATER + // Use .NET's IANA→Windows mapping to find the primary identifier + // Both "Asia/Calcutta" and "Asia/Kolkata" map to "India Standard Time" + // Then convert back to get the primary IANA name + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(timeZoneId, out var windowsId)) + { + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(windowsId, out var primaryId)) + { + return primaryId; + } + } +#endif + + // Fallback: resolve and return canonicalized form + return CanonicalizeTimeZone(timeZoneId); + } + private TimeZoneInfo? ResolveTimeZone(string timeZoneId) { return _timeZoneCache.GetOrAdd(timeZoneId, id => @@ -590,6 +709,13 @@ public string GetDefaultTimeZone() return TimeZoneInfo.CreateCustomTimeZone(id, roundedOffset, id, id); } + // On Windows, .NET resolves non-IANA identifiers (Java abbreviations like ACT, BST, etc.) + // Reject identifiers that don't follow IANA naming conventions + if (!IsValidIanaIdentifier(id)) + { + return null; + } + // Try direct lookup first try { @@ -600,7 +726,7 @@ public string GetDefaultTimeZone() // Continue to mapping } - // Try IANA to Windows mapping + // Try IANA to Windows mapping (case-insensitive via dictionary comparer) if (IsWindowsPlatform() && IanaToWindows.TryGetValue(id, out var windowsId)) { @@ -614,6 +740,17 @@ public string GetDefaultTimeZone() } } +#if NET6_0_OR_GREATER + // Case-insensitive fallback: try all system timezones + foreach (var systemTz in TimeZoneInfo.GetSystemTimeZones()) + { + if (string.Equals(systemTz.Id, id, StringComparison.OrdinalIgnoreCase)) + { + return systemTz; + } + } +#endif + return null; }); } @@ -709,12 +846,64 @@ private static BigInteger DateTimeToEpochNanoseconds( return totalNs; } + private static Dictionary BuildCanonicalCasingDict() + { + var dict = new Dictionary(IanaToWindows.Count, StringComparer.OrdinalIgnoreCase); + foreach (var key in IanaToWindows.Keys) + { + dict[key] = key; + } + +#if NET6_0_OR_GREATER + // Also add system timezone IDs (on .NET 6+ these include IANA IDs) + foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + if (!dict.ContainsKey(tz.Id)) + { + dict[tz.Id] = tz.Id; + } + } +#endif + + return dict; + } + private static long DaysSinceEpoch(int year, int month, int day) { // Use the same algorithm as TemporalHelpers.IsoDateToDays for consistency return TemporalHelpers.IsoDateToDays(year, month, day); } + /// + /// Validates that a timezone identifier follows IANA naming conventions. + /// Rejects Java/ICU-specific abbreviations like ACT, BST, JST that .NET may resolve. + /// + private static bool IsValidIanaIdentifier(string id) + { + // IDs containing '/' are Area/Location format (always valid IANA) + if (id.Contains('/')) + { + return true; + } + + // IDs containing digits with letters (like EST5EDT, CST6CDT) are IANA compound TZ names + // Known single-word IANA identifiers (Zone and Link names from TZDB) + return s_validSingleWordIanaIds.Contains(id); + } + + private static readonly HashSet s_validSingleWordIanaIds = new(StringComparer.OrdinalIgnoreCase) + { + "CET", "CST6CDT", "EET", "EST", "EST5EDT", "HST", + "MET", "MST", "MST7MDT", "PST8PDT", "WET", + "GMT", "GMT0", "GMT+0", "GMT-0", "UTC", "UCT", + "Universal", "Greenwich", "Zulu", "W-SU", + "Cuba", "Egypt", "Eire", "GB", "GB-Eire", + "Hongkong", "Iceland", "Iran", "Israel", "Jamaica", + "Japan", "Kwajalein", "Libya", "NZ", "NZ-CHAT", + "Navajo", "PRC", "Poland", "Portugal", "ROC", "ROK", + "Singapore", "Turkey", + }; + private static DateTime GetTransitionDateTime(TimeZoneInfo.TransitionTime transition, int year) { if (transition.IsFixedDateRule) diff --git a/Jint/Native/Temporal/Duration/DurationPrototype.cs b/Jint/Native/Temporal/Duration/DurationPrototype.cs index e6fbf909ec..aac61041ef 100644 --- a/Jint/Native/Temporal/Duration/DurationPrototype.cs +++ b/Jint/Native/Temporal/Duration/DurationPrototype.cs @@ -1562,12 +1562,30 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) /// /// https://tc39.es/proposal-temporal/#sec-temporal.duration.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var duration = ValidateDuration(thisObject); - // For now, just return the ISO string representation - // Full Intl.DurationFormat integration would require more work - return new JsString(TemporalHelpers.FormatDuration(duration.DurationRecord)); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: new Intl.DurationFormat(locales, options).format(this) + var durationFormat = (Intl.JsDurationFormat) _realm.Intrinsics.DurationFormat.Construct([locales, options], Undefined); + // Convert Temporal DurationRecord to Intl DurationRecord + var dr = duration.DurationRecord; + var intlRecord = new Intl.JsDurationFormat.DurationRecord + { + Years = dr.Years, + Months = dr.Months, + Weeks = dr.Weeks, + Days = dr.Days, + Hours = dr.Hours, + Minutes = dr.Minutes, + Seconds = dr.Seconds, + Milliseconds = dr.Milliseconds, + Microseconds = dr.Microseconds, + Nanoseconds = dr.Nanoseconds, + }; + return durationFormat.Format(intlRecord); } /// diff --git a/Jint/Native/Temporal/ITimeZoneProvider.cs b/Jint/Native/Temporal/ITimeZoneProvider.cs index b85cede530..da3e3f06c8 100644 --- a/Jint/Native/Temporal/ITimeZoneProvider.cs +++ b/Jint/Native/Temporal/ITimeZoneProvider.cs @@ -80,4 +80,11 @@ BigInteger[] GetPossibleInstantsFor( /// Gets the system's default time zone identifier. /// string GetDefaultTimeZone(); + + /// + /// Resolves a time zone alias to its primary IANA identifier. + /// E.g., "Asia/Calcutta" → "Asia/Kolkata", "US/Eastern" → "America/New_York". + /// Returns null if the time zone ID is not recognized. + /// + string? GetPrimaryTimeZoneIdentifier(string timeZoneId); } diff --git a/Jint/Native/Temporal/Instant/InstantPrototype.cs b/Jint/Native/Temporal/Instant/InstantPrototype.cs index a2fcbebb73..f9344e0573 100644 --- a/Jint/Native/Temporal/Instant/InstantPrototype.cs +++ b/Jint/Native/Temporal/Instant/InstantPrototype.cs @@ -787,17 +787,8 @@ private static void FormatIsoYear(ref ValueStringBuilder sb, int year) private static void FormatOffset(ref ValueStringBuilder sb, long offsetNs) { - var sign = offsetNs >= 0 ? '+' : '-'; - offsetNs = System.Math.Abs(offsetNs); - - var hours = offsetNs / 3_600_000_000_000L; - offsetNs %= 3_600_000_000_000L; - var minutes = offsetNs / 60_000_000_000L; - - sb.Append(sign); - sb.Append(hours.ToString("D2", CultureInfo.InvariantCulture)); - sb.Append(':'); - sb.Append(minutes.ToString("D2", CultureInfo.InvariantCulture)); + // Use FormatDateTimeUTCOffsetRounded: round to nearest minute, format as ±HH:MM + sb.Append(TemporalHelpers.FormatOffsetRounded(offsetNs)); } @@ -811,13 +802,26 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) } /// - /// https://tc39.es/proposal-temporal/#sec-temporal.instant.prototype.tolocalestring + /// https://tc39.es/proposal-temporal/#sup-temporal.instant.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var instant = ValidateInstant(thisObject); - // For now, just return the ISO string representation - return new JsString(FormatInstant(instant.EpochNanoseconds, "UTC", -1)); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~any~, defaults=~all~ + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Any, defaults: Intl.DateTimeDefaults.All); + + // Convert epoch nanoseconds to UTC DateTime (DTF will convert to its timezone) + const long nsPerTick = 100; + var ticks = (long) (instant.EpochNanoseconds / nsPerTick); + var unixEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + var totalTicks = unixEpochTicks + ticks; + var dateTime = new DateTime(totalTicks, DateTimeKind.Utc); + + return dtf.Format(dateTime); } /// diff --git a/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs b/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs index 9a78d0dfc4..05327598da 100644 --- a/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs +++ b/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs @@ -89,7 +89,7 @@ private JsPlainDate From(JsValue thisObject, JsCallArguments arguments) private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsValue options) { - // Read and convert properties in alphabetical order per spec: calendar, day, month, monthCode, year + // Read and convert properties in alphabetical order per spec: calendar, day, era, eraYear, month, monthCode, year // Each property must be fully read and converted before moving to the next // 1. calendar @@ -111,6 +111,9 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue); + // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and month) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + // 3. month - read and convert immediately var monthValue = obj.Get("month"); int month = 0; @@ -167,20 +170,31 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa month = monthFromCode; } - // 5. year - read and convert immediately (TYPE validation happens here) - var yearValue = obj.Get("year"); - if (yearValue.IsUndefined()) + // 5. year - use eraYear if computed, otherwise read from property + int year; + if (eraYear.HasValue) { - Throw.TypeError(_realm, "Missing required property: year"); + // Year was already computed from era/eraYear + year = eraYear.Value; + // Still read the year property for observable side effects, but don't require it + obj.Get("year"); } + else + { + var yearValue = obj.Get("year"); + if (yearValue.IsUndefined()) + { + Throw.TypeError(_realm, "Missing required property: year"); + } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } // 6. Read options AFTER all fields (but BEFORE algorithmic validation) var overflow = TemporalHelpers.GetOverflowOption(_realm, options); - // NOW validate monthCode suitability (ISO calendar checks) - after year type validation AND options reading - if (monthCodeStr is not null) + // Validate monthCode suitability - only for ISO/Gregorian calendars + if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar)) { // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') @@ -199,7 +213,7 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa Throw.TypeError(_realm, "month or monthCode is required"); } - var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); if (date is null) { Throw.RangeError(_realm, "Invalid date"); @@ -344,6 +358,9 @@ private JsPlainDate ToTemporalDateFromFields(ObjectInstance obj, string overflow var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue); + // 2.5. era/eraYear - read for era-supporting calendars + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + // 3. month - read and convert immediately var monthValue = obj.Get("month"); int month = 0; @@ -400,14 +417,23 @@ private JsPlainDate ToTemporalDateFromFields(ObjectInstance obj, string overflow } } - // 5. year - read and convert immediately - var yearValue = obj.Get("year"); - if (yearValue.IsUndefined()) + // 5. year - use eraYear if computed, otherwise read from property + int year; + if (eraYear.HasValue) { - Throw.TypeError(_realm, "Missing required property: year"); + year = eraYear.Value; + obj.Get("year"); } + else + { + var yearValue = obj.Get("year"); + if (yearValue.IsUndefined()) + { + Throw.TypeError(_realm, "Missing required property: year"); + } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } // Validate: both month and monthCode provided - they must match if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value) @@ -426,7 +452,7 @@ private JsPlainDate ToTemporalDateFromFields(ObjectInstance obj, string overflow Throw.TypeError(_realm, "month or monthCode is required"); } - var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); if (date is null) { Throw.RangeError(_realm, "Invalid date"); diff --git a/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs b/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs index 39f7d4029a..50bc54b850 100644 --- a/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs +++ b/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using System.Text; using Jint.Native.Object; using Jint.Native.Symbol; @@ -86,24 +87,39 @@ private JsPlainDate ValidatePlainDate(JsValue thisObject) private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainDate(thisObject); - return Undefined; + var pd = ValidatePlainDate(thisObject); + var era = TemporalHelpers.CalendarEra(pd.Calendar, pd.IsoDate.Year); + return era is not null ? new JsString(era) : Undefined; } private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainDate(thisObject); - return Undefined; + var pd = ValidatePlainDate(thisObject); + var eraYear = TemporalHelpers.CalendarEraYear(pd.Calendar, pd.IsoDate.Year); + return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } - private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Year); + private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarYear(pd.Calendar, pd.IsoDate.Year)); + } private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Month); private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainDate(thisObject).IsoDate.Month:D2}"); private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Day); private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DayOfWeek()); private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DayOfYear()); - private JsNumber GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.WeekOfYear()); - private JsNumber GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.YearOfWeek()); + private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return string.Equals(pd.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pd.IsoDate.WeekOfYear()) : Undefined; + } + + private JsValue GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return string.Equals(pd.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pd.IsoDate.YearOfWeek()) : Undefined; + } private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments) { @@ -279,11 +295,6 @@ private JsPlainDate WithCalendar(JsValue thisObject, JsCallArguments arguments) var calendarArg = arguments.At(0); var calendar = ToTemporalCalendarIdentifier(calendarArg); - if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal)) - { - Throw.RangeError(_realm, $"Unsupported calendar: {calendar}"); - } - return _constructor.Construct(plainDate.IsoDate, calendar); } @@ -342,6 +353,13 @@ private JsDuration Until(JsValue thisObject, JsCallArguments arguments) { var plainDate = ValidatePlainDate(thisObject); var other = _constructor.ToTemporalDate(arguments.At(0), "constrain"); + + // Step 3: Calendar equality check (before reading options per spec) + if (!string.Equals(plainDate.Calendar, other.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, "Calendars must match for date difference operations"); + } + var optionsArg = arguments.At(1); // Date units for PlainDate operations (no time units allowed) @@ -378,6 +396,13 @@ private JsDuration Since(JsValue thisObject, JsCallArguments arguments) { var plainDate = ValidatePlainDate(thisObject); var other = _constructor.ToTemporalDate(arguments.At(0), "constrain"); + + // Step 3: Calendar equality check (before reading options per spec) + if (!string.Equals(plainDate.Calendar, other.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, "Calendars must match for date difference operations"); + } + var optionsArg = arguments.At(1); // Date units for PlainDate operations (no time units allowed) @@ -544,12 +569,28 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) } /// - /// https://tc39.es/proposal-temporal/#sec-temporal.plaindate.prototype.tolocalestring + /// https://tc39.es/proposal-temporal/#sup-temporal.plaindate.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var plainDate = ValidatePlainDate(thisObject); - return new JsString(FormatDate(plainDate.IsoDate, plainDate.Calendar)); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~date~, defaults=~date~ + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Date, defaults: Intl.DateTimeDefaults.Date); + + // Calendar mismatch check: PlainDate calendar must be iso8601 or match DTF calendar + var cal = plainDate.Calendar; + if (!string.Equals(cal, "iso8601", StringComparison.Ordinal) && + dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, $"Calendar mismatch: PlainDate uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'"); + } + + // Use noon time to avoid DST issues, Unspecified kind (plain = no TZ adjustment) + return dtf.Format(new DateTime(plainDate.IsoDate.Year, plainDate.IsoDate.Month, plainDate.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), isPlain: true); } /// @@ -656,24 +697,22 @@ private JsZonedDateTime ToZonedDateTime(JsValue thisObject, JsCallArguments argu return null!; } - // Convert plainTime to IsoTime (or use midnight) - IsoTime time; + // Per spec step 8-10: If temporalTime is undefined, use GetStartOfDay; otherwise use GetEpochNanosecondsFor + BigInteger epochNs; if (plainTimeValue.IsUndefined()) { - time = new IsoTime(0, 0, 0, 0, 0, 0); + // Step 8: Let epochNs be GetStartOfDay(timeZone, plainDate.[[ISODate]]) + var provider = _engine.Options.Temporal.TimeZoneProvider; + epochNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, plainDate.IsoDate); } else { + // Step 10: Use GetEpochNanosecondsFor with compatible disambiguation var plainTime = _realm.Intrinsics.TemporalPlainTime.ToTemporalTime(plainTimeValue, "constrain"); - time = plainTime.IsoTime; + var dateTime = new IsoDateTime(plainDate.IsoDate, plainTime.IsoTime); + epochNs = GetEpochFromDateTime(dateTime, timeZone, "compatible"); } - // Combine date and time - var dateTime = new IsoDateTime(plainDate.IsoDate, time); - - // Get the epoch nanoseconds for this date-time in the given time zone - var epochNs = GetEpochFromDateTime(dateTime, timeZone, "compatible"); - if (!InstantConstructor.IsValidEpochNanoseconds(epochNs)) { Throw.RangeError(_realm, "Resulting ZonedDateTime is outside the valid range"); @@ -685,62 +724,7 @@ private JsZonedDateTime ToZonedDateTime(JsValue thisObject, JsCallArguments argu private System.Numerics.BigInteger GetEpochFromDateTime(IsoDateTime dateTime, string timeZone, string disambiguation) { - var provider = _engine.Options.Temporal.TimeZoneProvider; - var localNs = IsoDateTimeToNanoseconds(dateTime); - - var possibleInstants = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - dateTime.Hour, dateTime.Minute, dateTime.Second, - dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - - if (possibleInstants.Length == 1) - { - return possibleInstants[0]; - } - - if (possibleInstants.Length == 0) - { - // Gap - use disambiguation - var before = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - 0, 0, 0, 0, 0, 0); - if (before.Length > 0) - { - var instant = before[before.Length - 1]; - var offsetBefore = provider.GetOffsetNanosecondsFor(timeZone, instant); - var offsetAfter = provider.GetOffsetNanosecondsFor(timeZone, instant + 1); - - switch (disambiguation) - { - case "compatible": - case "later": - return localNs - offsetAfter; - case "earlier": - return localNs - offsetBefore; - case "reject": - Throw.RangeError(_realm, "No such time exists in this time zone (gap)"); - break; - } - } - - // Fallback - return localNs; - } - - // Ambiguous (fold/overlap) - switch (disambiguation) - { - case "compatible": - case "earlier": - return possibleInstants[0]; - case "later": - return possibleInstants[possibleInstants.Length - 1]; - case "reject": - Throw.RangeError(_realm, "Time is ambiguous in this time zone (fold)"); - break; - } - - return possibleInstants[0]; + return TemporalHelpers.GetEpochNanosecondsFor(_realm, _engine.Options.Temporal.TimeZoneProvider, timeZone, dateTime, disambiguation); } private static System.Numerics.BigInteger IsoDateTimeToNanoseconds(IsoDateTime dateTime) diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs index 63bf5a8c80..3e3d18138f 100644 --- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs +++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs @@ -222,7 +222,7 @@ internal JsPlainDateTime ToTemporalDateTime(JsValue item, string overflow) Throw.RangeError(_realm, "Invalid date-time string"); } - return Construct(parsed.DateTime.Value, "iso8601"); + return Construct(parsed.DateTime.Value, TemporalHelpers.ExtractCalendarIdentifierFromString(str)); } if (item.IsObject()) @@ -261,6 +261,9 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue); + // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and hour) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + // 3. hour - read and convert immediately var hourValue = obj.Get("hour"); var hour = hourValue.IsUndefined() ? 0 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, hourValue); @@ -330,14 +333,23 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue var secondValue = obj.Get("second"); var second = secondValue.IsUndefined() ? 0 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, secondValue); - // 11. year - read and convert immediately (TYPE validation happens here) - var yearValue = obj.Get("year"); - if (yearValue.IsUndefined()) + // 11. year - use eraYear if computed, otherwise read from property + int year; + if (eraYear.HasValue) { - Throw.TypeError(_realm, "Missing required property: year"); + year = eraYear.Value; + obj.Get("year"); } + else + { + var yearValue = obj.Get("year"); + if (yearValue.IsUndefined()) + { + Throw.TypeError(_realm, "Missing required property: year"); + } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } // 12. Read options.overflow AFTER all fields (but BEFORE algorithmic validation) var overflow = optionsValue.IsUndefined() ? "constrain" : TemporalHelpers.GetOverflowOption(_realm, optionsValue); @@ -376,13 +388,13 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue Throw.TypeError(_realm, "month or monthCode is required"); } - // Validate year range only (month/day will be validated by RegulateIsoDate based on overflow) - if (year < -271821 || year > 275760) + // Validate year range for ISO calendars only (non-ISO will validate during conversion) + if (TemporalHelpers.IsGregorianBasedCalendar(calendar) && (year < -271821 || year > 275760)) { Throw.RangeError(_realm, "Date is outside valid range"); } - var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); if (date is null) { Throw.RangeError(_realm, "Invalid date"); diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs index c36b9008bb..0a30f467aa 100644 --- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs +++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs @@ -95,17 +95,23 @@ private JsPlainDateTime ValidatePlainDateTime(JsValue thisObject) private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainDateTime(thisObject); - return Undefined; + var pdt = ValidatePlainDateTime(thisObject); + var era = TemporalHelpers.CalendarEra(pdt.Calendar, pdt.IsoDateTime.Year); + return era is not null ? new JsString(era) : Undefined; } private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainDateTime(thisObject); - return Undefined; + var pdt = ValidatePlainDateTime(thisObject); + var eraYear = TemporalHelpers.CalendarEraYear(pdt.Calendar, pdt.IsoDateTime.Year); + return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } - private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Year); + private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarYear(pdt.Calendar, pdt.IsoDateTime.Year)); + } private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Month); private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainDateTime(thisObject).IsoDateTime.Month:D2}"); private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Day); @@ -117,8 +123,17 @@ private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) private JsNumber GetNanosecond(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Nanosecond); private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.DayOfWeek()); private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.DayOfYear()); - private JsNumber GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.WeekOfYear()); - private JsNumber GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.YearOfWeek()); + private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return string.Equals(pdt.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pdt.IsoDateTime.Date.WeekOfYear()) : Undefined; + } + + private JsValue GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return string.Equals(pdt.Calendar, "iso8601", StringComparison.Ordinal) ? JsNumber.Create(pdt.IsoDateTime.Date.YearOfWeek()) : Undefined; + } private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments) { @@ -369,11 +384,6 @@ private JsPlainDateTime WithCalendar(JsValue thisObject, JsCallArguments argumen // Use ToTemporalCalendarIdentifier for spec-compliant conversion (handles Temporal objects) var calendar = TemporalHelpers.ToTemporalCalendarIdentifier(_realm, calendarArg); - if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal)) - { - Throw.RangeError(_realm, $"Unsupported calendar: {calendar}"); - } - return _constructor.Construct(plainDateTime.IsoDateTime, calendar); } @@ -451,6 +461,13 @@ private JsDuration Until(JsValue thisObject, JsCallArguments arguments) { var plainDateTime = ValidatePlainDateTime(thisObject); var other = _constructor.ToTemporalDateTime(arguments.At(0), "constrain"); + + // Calendar equality check (before reading options per spec) + if (!string.Equals(plainDateTime.Calendar, other.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, "Calendars must match for date-time difference operations"); + } + var optionsArg = arguments.At(1); // All temporal units allowed for PlainDateTime operations @@ -485,6 +502,13 @@ private JsDuration Since(JsValue thisObject, JsCallArguments arguments) { var plainDateTime = ValidatePlainDateTime(thisObject); var other = _constructor.ToTemporalDateTime(arguments.At(0), "constrain"); + + // Calendar equality check (before reading options per spec) + if (!string.Equals(plainDateTime.Calendar, other.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, "Calendars must match for date-time difference operations"); + } + var optionsArg = arguments.At(1); // All temporal units allowed for PlainDateTime operations @@ -867,12 +891,29 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) } /// - /// https://tc39.es/proposal-temporal/#sec-temporal.plaindatetime.prototype.tolocalestring + /// https://tc39.es/proposal-temporal/#sup-temporal.plaindatetime.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var plainDateTime = ValidatePlainDateTime(thisObject); - return new JsString(FormatDateTime(plainDateTime.IsoDateTime, plainDateTime.Calendar)); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~any~, defaults=~all~ + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Any, defaults: Intl.DateTimeDefaults.All); + + // Calendar mismatch check: if not iso8601 and not matching DTF calendar → RangeError + var cal = plainDateTime.Calendar; + if (!string.Equals(cal, "iso8601", StringComparison.Ordinal) && + dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, $"Calendar mismatch: PlainDateTime uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'"); + } + + var d = plainDateTime.IsoDateTime.Date; + var t = plainDateTime.IsoDateTime.Time; + return dtf.Format(new DateTime(d.Year, d.Month, d.Day, t.Hour, t.Minute, t.Second, t.Millisecond, DateTimeKind.Unspecified), isPlain: true); } /// @@ -968,62 +1009,7 @@ private static bool IsValidDisambiguation(string value) private System.Numerics.BigInteger GetEpochFromDateTime(IsoDateTime dateTime, string timeZone, string disambiguation) { - var provider = _engine.Options.Temporal.TimeZoneProvider; - var localNs = IsoDateTimeToNanoseconds(dateTime); - - var possibleInstants = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - dateTime.Hour, dateTime.Minute, dateTime.Second, - dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - - if (possibleInstants.Length == 1) - { - return possibleInstants[0]; - } - - if (possibleInstants.Length == 0) - { - // Gap - use disambiguation - var before = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - 0, 0, 0, 0, 0, 0); - if (before.Length > 0) - { - var instant = before[before.Length - 1]; - var offsetBefore = provider.GetOffsetNanosecondsFor(timeZone, instant); - var offsetAfter = provider.GetOffsetNanosecondsFor(timeZone, instant + 1); - - switch (disambiguation) - { - case "compatible": - case "later": - return localNs - offsetAfter; - case "earlier": - return localNs - offsetBefore; - case "reject": - Throw.RangeError(_realm, "No such time exists in this time zone (gap)"); - break; - } - } - - // Fallback - return localNs; - } - - // Ambiguous (fold/overlap) - switch (disambiguation) - { - case "compatible": - case "earlier": - return possibleInstants[0]; - case "later": - return possibleInstants[possibleInstants.Length - 1]; - case "reject": - Throw.RangeError(_realm, "Time is ambiguous in this time zone (overlap)"); - break; - } - - return possibleInstants[0]; + return TemporalHelpers.GetEpochNanosecondsFor(_realm, _engine.Options.Temporal.TimeZoneProvider, timeZone, dateTime, disambiguation); } private static System.Numerics.BigInteger IsoDateTimeToNanoseconds(IsoDateTime dateTime) diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs index 4aa3c66ee6..228e9badba 100644 --- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs +++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs @@ -160,13 +160,13 @@ internal JsPlainMonthDay ToTemporalMonthDay(JsValue item, string overflow) if (item.IsString()) { var str = item.ToString(); - var parsed = ParseMonthDayString(str); + var parsed = ParseMonthDayString(str, out var parsedCalendar); if (parsed is null) { Throw.RangeError(_realm, "Invalid month-day string"); } - return Construct(parsed.Value, "iso8601"); + return Construct(parsed.Value, parsedCalendar); } if (item.IsObject()) @@ -204,6 +204,9 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue); + // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and month) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + // 3. month - read and convert immediately var monthValue = obj.Get("month"); int month = 0; @@ -249,15 +252,26 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCodeStr); } - // 5. year - read and convert immediately (TYPE validation happens here) - var yearValue = obj.Get("year"); - var year = yearValue.IsUndefined() ? 1972 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + // 5. year - use eraYear if computed, otherwise read from property + int year; + var yearExplicitlyProvided = eraYear.HasValue; + if (eraYear.HasValue) + { + year = eraYear.Value; + obj.Get("year"); + } + else + { + var yearValue = obj.Get("year"); + yearExplicitlyProvided = !yearValue.IsUndefined(); + year = yearValue.IsUndefined() ? 1972 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } // 6. Read options.overflow AFTER all fields (but BEFORE algorithmic validation) var overflow = optionsValue.IsUndefined() ? "constrain" : TemporalHelpers.GetOverflowOption(_realm, optionsValue); - // NOW validate monthCode suitability (ISO calendar checks) - after year type validation AND options reading - if (monthCodeStr is not null) + // Validate monthCode suitability - only for ISO/Gregorian calendars + if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar)) { // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') @@ -272,6 +286,13 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue } // Now validate and combine month/monthCode + // For non-ISO calendars, monthCode is required unless year is explicitly provided + // (month alone is ambiguous in calendars with leap months) + if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal) && monthCodeStr is null && !yearExplicitlyProvided) + { + Throw.TypeError(_realm, "monthCode is required for non-ISO calendars when year is not provided"); + } + // Validate: both month and monthCode provided - they must match if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value) { @@ -289,6 +310,24 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue Throw.TypeError(_realm, "month or monthCode is required"); } + // For non-ISO calendars, convert calendar year/month/day to ISO + if (!TemporalHelpers.IsGregorianBasedCalendar(calendar)) + { + // When year is not explicitly provided, find the calendar year that maps to ISO 1972 + var calendarYear = yearExplicitlyProvided + ? year + : TemporalHelpers.FindCalendarReferenceYear(calendar, 1972, month, day); + + var calDate = TemporalHelpers.CalendarDateToISO(_realm, calendar, calendarYear, month, day, overflow); + if (calDate is null) + { + Throw.RangeError(_realm, "Invalid month-day"); + } + + // The converted ISO date becomes the reference date + return Construct(calDate.Value, calendar); + } + // Use input year for validation, but always use 1972 as reference year in result // Per spec (calendar.html CalendarMonthDayToISOReferenceDate): // "The reference year is always 1972" @@ -302,8 +341,10 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue return Construct(new IsoDate(1972, date.Value.Month, date.Value.Day), calendar); } - private static IsoDate? ParseMonthDayString(string input) + private static IsoDate? ParseMonthDayString(string input, out string parsedCalendar) { + parsedCalendar = "iso8601"; + // Empty strings are invalid if (string.IsNullOrEmpty(input)) { @@ -317,7 +358,18 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue // Annotation parsing error return null; } - // Note: calendar is ignored for PlainMonthDay (always uses iso8601) + // Canonicalize calendar annotation if present + var hasNonIsoCalendar = false; + if (calendar is not null) + { + var canonical = TemporalHelpers.CanonicalizeCalendar(calendar); + if (canonical is null) + { + return null; // Invalid calendar + } + parsedCalendar = canonical; + hasNonIsoCalendar = !string.Equals(canonical, "iso8601", StringComparison.Ordinal); + } // PlainMonthDay cannot have UTC designator if (coreString.Contains('Z')) @@ -368,6 +420,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject"); if (date is not null) { + if (hasNonIsoCalendar) return null; return date.Value; } } @@ -381,6 +434,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject"); if (date is not null) { + if (hasNonIsoCalendar) return null; return date.Value; } } @@ -395,6 +449,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject"); if (date is not null) { + if (hasNonIsoCalendar) return null; return date.Value; } } @@ -427,6 +482,7 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue var date = TemporalHelpers.RegulateIsoDate(1972, month, day, "reject"); if (date is not null) { + if (hasNonIsoCalendar) return null; return date.Value; } } @@ -435,15 +491,22 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue } // Try parsing as full date and extract month-day - return TryParseFullDateAsMonthDay(coreString); + return TryParseFullDateAsMonthDay(coreString, hasNonIsoCalendar); } - private static IsoDate? TryParseFullDateAsMonthDay(string input) + private static IsoDate? TryParseFullDateAsMonthDay(string input, bool hasNonIsoCalendar) { var parsed = TemporalHelpers.ParseIsoDate(input); if (parsed is not null) { - // For PlainMonthDay, we only extract month and day — year range validation is not needed + // For non-iso8601 calendars, the parsed year is used for calendar conversion + // so it must be within valid ISO limits + if (hasNonIsoCalendar && !TemporalHelpers.IsValidIsoDateTime(parsed.Value.Year, parsed.Value.Month, parsed.Value.Day)) + { + return null; + } + + // For PlainMonthDay, we only extract month and day — year range validation is not needed for iso8601 // Use 1972 as reference year (leap year, so Feb 29 is valid) return new IsoDate(1972, parsed.Value.Month, parsed.Value.Day); } diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs index a94522cb17..4b130e177b 100644 --- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs +++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs @@ -153,6 +153,13 @@ private JsPlainMonthDay With(JsValue thisObject, JsCallArguments arguments) // Read options BEFORE any validation (per spec) var overflow = TemporalHelpers.GetOverflowOption(_realm, optionsArg); + // For non-ISO calendars, monthCode is required when month is provided + if (!string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal) && + !monthProp.IsUndefined() && monthCodeProp.IsUndefined()) + { + Throw.TypeError(_realm, "monthCode is required for non-ISO calendars"); + } + // NOW validate monthCode (after options are read) int? monthFromCode = null; if (monthCode is not null) @@ -224,19 +231,32 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments) var options = TemporalHelpers.GetOptionsObject(_realm, optionsValue); var showCalendar = GetCalendarNameOption(options); - // Include the reference year when calendar is shown - // Per spec: YYYY-MM-DD format with calendar annotation - var includeYear = string.Equals(showCalendar, "always", StringComparison.Ordinal) || - string.Equals(showCalendar, "critical", StringComparison.Ordinal) || - (string.Equals(showCalendar, "auto", StringComparison.Ordinal) && - !string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal)); + // For non-ISO calendars, always include the year (month-day alone is ambiguous) + // The year is needed regardless of calendarName option + var isNonIsoCalendar = !string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal); + + // Include the calendar annotation based on the calendarName option + var includeCalendar = string.Equals(showCalendar, "always", StringComparison.Ordinal) || + string.Equals(showCalendar, "critical", StringComparison.Ordinal) || + (string.Equals(showCalendar, "auto", StringComparison.Ordinal) && isNonIsoCalendar); + + // Include year for non-ISO calendar or when calendar annotation is shown + var includeYear = isNonIsoCalendar || includeCalendar; string result; if (includeYear) { - // Add critical flag (!) if showCalendar is "critical" - var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : ""; - result = $"{md.IsoDate.Year:D4}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}[{criticalFlag}u-ca={md.Calendar}]"; + var yearStr = TemporalHelpers.PadIsoYear(md.IsoDate.Year); + if (includeCalendar) + { + // Add critical flag (!) if showCalendar is "critical" + var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : ""; + result = $"{yearStr}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}[{criticalFlag}u-ca={md.Calendar}]"; + } + else + { + result = $"{yearStr}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}"; + } } else { @@ -252,17 +272,38 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments) private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) { var md = ValidatePlainMonthDay(thisObject); + // toJSON uses "auto" for calendarName: include year+calendar for non-ISO + if (!string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal)) + { + var yearStr = TemporalHelpers.PadIsoYear(md.IsoDate.Year); + return new JsString($"{yearStr}-{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}[u-ca={md.Calendar}]"); + } + return new JsString($"{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}"); } /// - /// https://tc39.es/proposal-temporal/#sec-temporal.plainmonthday.prototype.tolocalestring + /// https://tc39.es/proposal-temporal/#sup-temporal.plainmonthday.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var md = ValidatePlainMonthDay(thisObject); - // For now, just return MM-DD format - return new JsString($"{md.IsoDate.Month:D2}-{md.IsoDate.Day:D2}"); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~date~, defaults=~date~ + // But for PlainMonthDay, we use month-day specific defaults (no year) + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Date, defaults: Intl.DateTimeDefaults.MonthDay); + + // Calendar mismatch check: PlainMonthDay calendar must match DTF calendar exactly + var cal = md.Calendar; + if (dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, $"Calendar mismatch: PlainMonthDay uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'"); + } + + return dtf.Format(new DateTime(md.IsoDate.Year, md.IsoDate.Month, md.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), isPlain: true); } /// @@ -288,13 +329,27 @@ private JsPlainDate ToPlainDate(JsValue thisObject, JsCallArguments arguments) } var obj = item.AsObject(); - var yearProp = obj.Get("year"); - if (yearProp.IsUndefined()) + + // Read era/eraYear for era-supporting calendars (alphabetically before year) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, md.Calendar); + + int year; + if (eraYear.HasValue) + { + year = eraYear.Value; + obj.Get("year"); + } + else { - Throw.TypeError(_realm, "year is required"); + var yearProp = obj.Get("year"); + if (yearProp.IsUndefined()) + { + Throw.TypeError(_realm, "year is required"); + } + + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearProp); } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearProp); var date = TemporalHelpers.RegulateIsoDate(year, md.IsoDate.Month, md.IsoDate.Day, "constrain"); if (date is null) { diff --git a/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs b/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs index a1d8c8a879..51b709247e 100644 --- a/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs +++ b/Jint/Native/Temporal/PlainTime/PlainTimePrototype.cs @@ -528,12 +528,21 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) } /// - /// https://tc39.es/proposal-temporal/#sec-temporal.plaintime.prototype.tolocalestring + /// https://tc39.es/proposal-temporal/#sup-temporal.plaintime.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var plainTime = ValidatePlainTime(thisObject); - return new JsString(FormatTime(plainTime.IsoTime, -1)); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~time~, defaults=~time~ + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Time, defaults: Intl.DateTimeDefaults.Time); + + // Format: use 1970-01-01 as reference date, Unspecified kind (plain = no TZ adjustment) + var t = plainTime.IsoTime; + return dtf.Format(new DateTime(1970, 1, 1, t.Hour, t.Minute, t.Second, t.Millisecond, DateTimeKind.Unspecified), isPlain: true); } /// diff --git a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs index 976eb00c83..cca58d790d 100644 --- a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs +++ b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs @@ -189,7 +189,7 @@ internal JsPlainYearMonth ToTemporalYearMonth(JsValue item, string overflow) if (item.IsString()) { var str = item.ToString(); - var parsed = ParseYearMonthString(str); + var parsed = ParseYearMonthString(str, out var parsedCalendar); if (parsed is null) { Throw.RangeError(_realm, "Invalid year-month string"); @@ -200,7 +200,10 @@ internal JsPlainYearMonth ToTemporalYearMonth(JsValue item, string overflow) Throw.RangeError(_realm, "Year-month is outside the representable range"); } - return Construct(parsed.Value, "iso8601"); + // Per spec: ISOYearMonthFromFields uses day=1 for the ISO calendar reference day. + // Non-iso8601 calendars preserve the parsed day as the reference ISO day. + var refDay = string.Equals(parsedCalendar, "iso8601", StringComparison.Ordinal) ? 1 : parsed.Value.Day; + return Construct(new IsoDate(parsed.Value.Year, parsed.Value.Month, refDay), parsedCalendar); } if (item.IsObject()) @@ -231,7 +234,10 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance calendar = TemporalHelpers.ToTemporalCalendarIdentifier(_realm, calendarValue); } - // 2. month - read and convert immediately + // 2. era/eraYear - read for era-supporting calendars (alphabetically between calendar and month) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + + // 3. month - read and convert immediately var monthValue = obj.Get("month"); int month = 0; if (!monthValue.IsUndefined()) @@ -239,7 +245,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance month = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthValue); } - // 3. monthCode - read and convert immediately, validate well-formedness + // 4. monthCode - read and convert immediately, validate well-formedness var monthCodeValue = obj.Get("monthCode"); string? monthCodeStr = null; int? monthFromCode = null; @@ -284,20 +290,29 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance month = monthFromCode.Value; } - // 4. year - read and convert immediately (TYPE validation happens here) - var yearValue = obj.Get("year"); - if (yearValue.IsUndefined()) + // 5. year - use eraYear if computed, otherwise read from property + int year; + if (eraYear.HasValue) { - Throw.TypeError(_realm, "Missing required property: year"); + year = eraYear.Value; + obj.Get("year"); } + else + { + var yearValue = obj.Get("year"); + if (yearValue.IsUndefined()) + { + Throw.TypeError(_realm, "Missing required property: year"); + } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } - // 5. Read options AFTER all fields (but BEFORE algorithmic validation) + // 6. Read options AFTER all fields (but BEFORE algorithmic validation) var overflow = TemporalHelpers.GetOverflowOption(_realm, options); - // NOW validate monthCode suitability (ISO calendar checks) - after year type validation AND options reading - if (monthCodeStr is not null) + // Validate monthCode suitability - only for ISO/Gregorian calendars + if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar)) { // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') @@ -317,7 +332,19 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance Throw.TypeError(_realm, "month or monthCode is required"); } - // Validate month range + // For non-ISO calendars, convert calendar year/month to ISO + if (!TemporalHelpers.IsGregorianBasedCalendar(calendar)) + { + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow); + if (date is null) + { + Throw.RangeError(_realm, "Invalid year-month"); + } + + return Construct(date.Value, calendar); + } + + // Validate month range (ISO calendars) if (month < 1 || month > 12) { if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) @@ -357,7 +384,10 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin calendar = TemporalHelpers.ToTemporalCalendarIdentifier(_realm, calendarValue); } - // 2. month - read and convert immediately + // 2. era/eraYear - read for era-supporting calendars (alphabetically between calendar and month) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + + // 3. month - read and convert immediately var monthValue = obj.Get("month"); int month = 0; if (!monthValue.IsUndefined()) @@ -365,7 +395,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin month = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthValue); } - // 3. monthCode - read and convert immediately, validate well-formedness + // 4. monthCode - read and convert immediately, validate well-formedness var monthCodeValue = obj.Get("monthCode"); string? monthCodeStr = null; int? monthFromCode = null; @@ -410,17 +440,26 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin month = monthFromCode.Value; } - // 4. year - read and convert immediately (TYPE validation happens here) - var yearValue = obj.Get("year"); - if (yearValue.IsUndefined()) + // 5. year - use eraYear if computed, otherwise read from property + int year; + if (eraYear.HasValue) { - Throw.TypeError(_realm, "Missing required property: year"); + year = eraYear.Value; + obj.Get("year"); } + else + { + var yearValue = obj.Get("year"); + if (yearValue.IsUndefined()) + { + Throw.TypeError(_realm, "Missing required property: year"); + } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } - // NOW validate monthCode suitability(ISO calendar checks) - after year type validation - if (monthCodeStr is not null) + // Validate monthCode suitability - only for ISO/Gregorian calendars + if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar)) { // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') @@ -442,7 +481,19 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin // Note: overflow option is already read in From() method before calling this method, per spec - // Validate month range + // For non-ISO calendars, convert calendar year/month to ISO + if (!TemporalHelpers.IsGregorianBasedCalendar(calendar)) + { + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow); + if (date is null) + { + Throw.RangeError(_realm, "Invalid year-month"); + } + + return Construct(date.Value, calendar); + } + + // Validate month range (ISO calendars) if (month < 1 || month > 12) { if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) @@ -464,8 +515,10 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin return Construct(new IsoDate(year, month, 1), calendar); } - private static IsoDate? ParseYearMonthString(string input) + private static IsoDate? ParseYearMonthString(string input, out string parsedCalendar) { + parsedCalendar = "iso8601"; + // Empty strings are invalid if (string.IsNullOrEmpty(input)) { @@ -479,7 +532,21 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin // Annotation parsing error return null; } - // Note: calendar is ignored for PlainYearMonth (always uses iso8601) + + // Canonicalize calendar annotation if present + if (calendar is not null) + { + var canonical = TemporalHelpers.CanonicalizeCalendar(calendar); + if (canonical is null) + { + return null; // Invalid calendar + } + parsedCalendar = canonical; + } + + // Per spec: DateSpecYearMonth (YYYY-MM) with non-iso8601 calendar annotation is invalid. + // Only full date formats (AnnotatedDateTime) support non-iso8601 calendars. + var hasNonIsoCalendar = calendar is not null && !string.Equals(parsedCalendar, "iso8601", StringComparison.Ordinal); // Check for negative zero year var firstDash = coreString.IndexOf('-', 1); @@ -551,6 +618,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin return null; } + if (hasNonIsoCalendar) return null; if (month >= 1 && month <= 12) { return new IsoDate(year, month, 1); @@ -559,6 +627,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin // Other suffix (like '[' for annotations) - also valid else { + if (hasNonIsoCalendar) return null; if (month >= 1 && month <= 12) { return new IsoDate(year, month, 1); @@ -568,6 +637,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin else { // No suffix - just YYYY-MM + if (hasNonIsoCalendar) return null; if (month >= 1 && month <= 12) { return new IsoDate(year, month, 1); @@ -606,6 +676,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin int.TryParse(monthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var month) && month >= 1 && month <= 12) { + if (hasNonIsoCalendar) return null; return new IsoDate(year, month, 1); } } @@ -619,17 +690,18 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin int.TryParse(monthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var month) && month >= 1 && month <= 12) { + if (hasNonIsoCalendar) return null; return new IsoDate(year, month, 1); } } } } - // Try parsing as full date and extract year/month + // Try parsing as full date and extract year/month (preserve day as reference day per spec) var parsed = TemporalHelpers.ParseIsoDate(coreString); if (parsed is not null) { - return new IsoDate(parsed.Value.Year, parsed.Value.Month, 1); + return parsed.Value; } // Try parsing date-time string @@ -651,7 +723,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin return null; } - return new IsoDate(parsed.Value.Year, parsed.Value.Month, 1); + return parsed.Value; } } diff --git a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs index df6f0a47dd..0e34364b77 100644 --- a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs +++ b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs @@ -72,7 +72,11 @@ private JsPlainYearMonth ValidatePlainYearMonth(JsValue thisObject) // Getters private JsString GetCalendarId(JsValue thisObject, JsCallArguments arguments) => new JsString(ValidatePlainYearMonth(thisObject).Calendar); - private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.Year); + private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) + { + var ym = ValidatePlainYearMonth(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarYear(ym.Calendar, ym.IsoDate.Year)); + } private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.Month); private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainYearMonth(thisObject).IsoDate.Month:D2}"); private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.DaysInMonth()); @@ -85,13 +89,15 @@ private JsNumber GetMonthsInYear(JsValue thisObject, JsCallArguments arguments) private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) => IsoDate.IsLeapYear(ValidatePlainYearMonth(thisObject).IsoDate.Year) ? JsBoolean.True : JsBoolean.False; private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainYearMonth(thisObject); - return Undefined; // ISO calendar has no era + var ym = ValidatePlainYearMonth(thisObject); + var eraYear = TemporalHelpers.CalendarEraYear(ym.Calendar, ym.IsoDate.Year); + return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainYearMonth(thisObject); - return Undefined; // ISO calendar has no era + var ym = ValidatePlainYearMonth(thisObject); + var era = TemporalHelpers.CalendarEra(ym.Calendar, ym.IsoDate.Year); + return era is not null ? new JsString(era) : Undefined; } /// @@ -316,6 +322,13 @@ private JsDuration Until(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); var other = _constructor.ToTemporalYearMonth(arguments.At(0), "constrain"); + + // Calendar equality check (before reading options per spec) + if (!string.Equals(ym.Calendar, other.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, "Calendars must match for year-month difference operations"); + } + var optionsArg = arguments.At(1); // Only year and month units allowed for PlainYearMonth @@ -350,6 +363,13 @@ private JsDuration Since(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); var other = _constructor.ToTemporalYearMonth(arguments.At(0), "constrain"); + + // Calendar equality check (before reading options per spec) + if (!string.Equals(ym.Calendar, other.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, "Calendars must match for year-month difference operations"); + } + var optionsArg = arguments.At(1); // Only year and month units allowed for PlainYearMonth @@ -493,24 +513,32 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments) var options = TemporalHelpers.GetOptionsObject(_realm, optionsValue); var showCalendar = GetCalendarNameOption(options); - // Include the reference day when calendar is shown - // Per spec: YYYY-MM-DD format with calendar annotation - var includeDay = string.Equals(showCalendar, "always", StringComparison.Ordinal) || - string.Equals(showCalendar, "critical", StringComparison.Ordinal) || - (string.Equals(showCalendar, "auto", StringComparison.Ordinal) && - !string.Equals(ym.Calendar, "iso8601", StringComparison.Ordinal)); + // For non-ISO calendars, always include the reference day (year-month alone is ambiguous) + var isNonIsoCalendar = !string.Equals(ym.Calendar, "iso8601", StringComparison.Ordinal); - var year = ym.IsoDate.Year; - var yearStr = (year < 0 || year > 9999) - ? $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}" - : $"{year:D4}"; + // Include the calendar annotation based on the calendarName option + var includeCalendar = string.Equals(showCalendar, "always", StringComparison.Ordinal) || + string.Equals(showCalendar, "critical", StringComparison.Ordinal) || + (string.Equals(showCalendar, "auto", StringComparison.Ordinal) && isNonIsoCalendar); + + // Include reference day for non-ISO calendar or when calendar annotation is shown + var includeDay = isNonIsoCalendar || includeCalendar; + + var yearStr = TemporalHelpers.PadIsoYear(ym.IsoDate.Year); string result; if (includeDay) { - // Add critical flag (!) if showCalendar is "critical" - var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : ""; - result = $"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}[{criticalFlag}u-ca={ym.Calendar}]"; + if (includeCalendar) + { + // Add critical flag (!) if showCalendar is "critical" + var criticalFlag = string.Equals(showCalendar, "critical", StringComparison.Ordinal) ? "!" : ""; + result = $"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}[{criticalFlag}u-ca={ym.Calendar}]"; + } + else + { + result = $"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}"; + } } else { @@ -526,25 +554,38 @@ private JsString ToTemporalString(JsValue thisObject, JsCallArguments arguments) private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); - var year = ym.IsoDate.Year; - var yearStr = (year < 0 || year > 9999) - ? $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}" - : $"{year:D4}"; + var yearStr = TemporalHelpers.PadIsoYear(ym.IsoDate.Year); + // toJSON uses "auto" for calendarName: include day+calendar for non-ISO + if (!string.Equals(ym.Calendar, "iso8601", StringComparison.Ordinal)) + { + return new JsString($"{yearStr}-{ym.IsoDate.Month:D2}-{ym.IsoDate.Day:D2}[u-ca={ym.Calendar}]"); + } + return new JsString($"{yearStr}-{ym.IsoDate.Month:D2}"); } /// - /// https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.prototype.tolocalestring + /// https://tc39.es/proposal-temporal/#sup-temporal.plainyearmonth.prototype.tolocalestring /// - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); - // For now, just return ISO format - var year = ym.IsoDate.Year; - var yearStr = (year < 0 || year > 9999) - ? $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}" - : $"{year:D4}"; - return new JsString($"{yearStr}-{ym.IsoDate.Month:D2}"); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~date~, defaults=~date~ + // But for PlainYearMonth, we use year-month specific defaults (no day) + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Date, defaults: Intl.DateTimeDefaults.YearMonth); + + // Calendar mismatch check: PlainYearMonth calendar must match DTF calendar exactly + var cal = ym.Calendar; + if (dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, $"Calendar mismatch: PlainYearMonth uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'"); + } + + return dtf.Format(new DateTime(ym.IsoDate.Year, ym.IsoDate.Month, ym.IsoDate.Day, 12, 0, 0, DateTimeKind.Unspecified), isPlain: true); } /// diff --git a/Jint/Native/Temporal/TemporalHelpers.cs b/Jint/Native/Temporal/TemporalHelpers.cs index bf3cb3086f..455ad9e74f 100644 --- a/Jint/Native/Temporal/TemporalHelpers.cs +++ b/Jint/Native/Temporal/TemporalHelpers.cs @@ -13,6 +13,19 @@ namespace Jint.Native.Temporal; /// internal static class TemporalHelpers { + /// + /// Formats an ISO year as 4-digit (0000-9999) or 6-digit with sign prefix. + /// + internal static string PadIsoYear(int year) + { + if (year < 0 || year > 9999) + { + return $"{(year >= 0 ? '+' : '-')}{System.Math.Abs(year):D6}"; + } + + return $"{year:D4}"; + } + // Polyfill for Math.Clamp which doesn't exist in netstandard2.0 private static int Clamp(int value, int min, int max) { @@ -262,7 +275,22 @@ public static IsoDateTime EpochNanosecondsToIsoDateTime(BigInteger epochNs) } /// - /// Formats an offset in nanoseconds as an ISO 8601 offset string. + /// FormatDateTimeUTCOffsetRounded: Rounds offset to nearest minute and formats as ±HH:MM. + /// Used by ZonedDateTime.toString() and Instant.toString() per the Temporal spec. + /// + public static string FormatOffsetRounded(long offsetNanoseconds) + { + // Round to nearest minute (halfExpand) + var sign = offsetNanoseconds >= 0 ? "+" : "-"; + var absNs = System.Math.Abs(offsetNanoseconds); + var totalMinutes = (absNs + NanosecondsPerMinute / 2) / NanosecondsPerMinute; + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + return $"{sign}{hours:D2}:{minutes:D2}"; + } + + /// + /// Formats an offset in nanoseconds as an ISO 8601 offset string (full precision). /// public static string FormatOffsetString(long offsetNanoseconds) { @@ -1257,8 +1285,10 @@ public static bool IsSubMinuteOffset(string offset) } /// - /// Validates a time zone ID against the provider and returns the canonical form. + /// Validates a time zone ID against the provider and returns the case-corrected form. + /// Per spec, returns [[Identifier]] (not [[PrimaryIdentifier]]). /// Throws RangeError if the ID is not a valid time zone. + /// https://tc39.es/proposal-temporal/#sec-temporal-totemporaltimezoneidentifier /// public static string ValidateTimeZoneId(Engine engine, Realm realm, string timeZoneId) { @@ -1277,6 +1307,37 @@ public static string ValidateTimeZoneId(Engine engine, Realm realm, string timeZ return timeZoneId; } + /// + /// Compares two time zone identifiers for equality per TC39 TimeZoneEquals. + /// Resolves IANA aliases to their primary identifiers before comparing. + /// https://tc39.es/proposal-temporal/#sec-timezoneequals + /// + public static bool TimeZoneEquals(Engine engine, string one, string two) + { + if (string.Equals(one, two, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var provider = engine.Options.Temporal.TimeZoneProvider; + var primary1 = provider.GetPrimaryTimeZoneIdentifier(one); + var primary2 = provider.GetPrimaryTimeZoneIdentifier(two); + if (primary1 is not null && primary2 is not null) + { + return string.Equals(primary1, primary2, StringComparison.OrdinalIgnoreCase); + } + + // Fallback: compare canonicalized forms + var canonical1 = provider.CanonicalizeTimeZone(one); + var canonical2 = provider.CanonicalizeTimeZone(two); + if (canonical1 is not null && canonical2 is not null) + { + return string.Equals(canonical1, canonical2, StringComparison.OrdinalIgnoreCase); + } + + return false; + } + /// /// Parses a time zone string and returns the canonical time zone identifier. /// Handles IANA IDs, offset strings, and ISO date-time strings with embedded time zones. @@ -1319,15 +1380,19 @@ public static string ParseTemporalTimeZoneString(Engine engine, Realm realm, str } // Check if this looks like an ISO datetime string (contains T/t separator) - // T must be at position >= 4 to be a date-time separator (after YYYY year portion) - // This avoids treating timezone names like "UTC" as ISO datetime strings + // ISO datetime strings start with digits (year portion): "2024-07-02T..." + // IANA timezone names start with letters: "Asia/Kolkata", "America/Los_Angeles" + // Only look for T separator if the input starts with a digit (indicating a date) var tPos = -1; - for (var i = 4; i < input.Length; i++) + if (input.Length > 4 && char.IsDigit(input[0])) { - if (input[i] == 'T' || input[i] == 't') + for (var i = 4; i < input.Length; i++) { - tPos = i; - break; + if (input[i] == 'T' || input[i] == 't') + { + tPos = i; + break; + } } } @@ -1491,11 +1556,412 @@ public static bool ISOYearMonthWithinLimits(int year, int month) // Check if it's a valid calendar ID and return canonical form var lower = ToAsciiLowerCase(calendar); - // Only iso8601 is supported for now - if (string.Equals(lower, "iso8601", StringComparison.Ordinal)) - return "iso8601"; + // Map of known calendar identifiers to their canonical forms + // https://tc39.es/ecma402/#sec-availablecalendars + return lower switch + { + "iso8601" => "iso8601", + "gregory" or "gregorian" => "gregory", + "buddhist" => "buddhist", + "chinese" => "chinese", + "coptic" => "coptic", + "dangi" => "dangi", + "ethioaa" or "ethiopic-amete-alem" => "ethioaa", + "ethiopic" => "ethiopic", + "hebrew" => "hebrew", + "indian" => "indian", + "islamic" => "islamic", + "islamic-civil" => "islamic-civil", + "islamic-rgsa" => "islamic-rgsa", + "islamic-tbla" => "islamic-tbla", + "islamic-umalqura" => "islamic-umalqura", + "islamicc" => "islamic-civil", + "japanese" => "japanese", + "persian" => "persian", + "roc" => "roc", + _ => null // Unsupported calendar + }; + } + + /// + /// Converts calendar-system year/month/day to ISO year/month/day. + /// For non-ISO calendars, the input fields are in the calendar's system + /// and need to be converted to the proleptic Gregorian (ISO 8601) calendar. + /// + public static IsoDate? CalendarDateToISO(Realm realm, string calendar, int year, int month, int day, string overflow) + { + if (calendar is "iso8601" or "gregory") + { + return RegulateIsoDate(year, month, day, overflow); + } + + if (calendar is "islamic-civil") + { + return IslamicCivilDateToISO(year, month, day, overflow); + } + + if (calendar is "islamic-tbla") + { + return IslamicTblaDateToISO(year, month, day, overflow); + } + + // For other calendars, fall back to treating fields as ISO (best effort) + return RegulateIsoDate(year, month, day, overflow); + } + + /// + /// For PlainMonthDay without an explicit year: finds a calendar year such that + /// converting (calendarYear, month, day) to ISO produces an ISO date with the given + /// ISO reference year (typically 1972). + /// + internal static int FindCalendarReferenceYear(string calendar, int isoReferenceYear, int month, int day) + { + if (calendar is "islamic-civil" or "islamic-tbla") + { + // Approximate Islamic year for a given ISO year + var approxYear = (int) ((isoReferenceYear - 622.0) * 33.0 / 32.0); + + // Try years around the approximation to find one mapping to the ISO reference year + for (var y = approxYear - 1; y <= approxYear + 1; y++) + { + var isoDate = CalendarDateToISO(null!, calendar, y, month, day, "constrain"); + if (isoDate?.Year == isoReferenceYear) + { + return y; + } + } + + return approxYear; + } + + // Default: assume calendar year ≈ ISO year + return isoReferenceYear; + } + + /// + /// Returns the number of days in a given month of the Islamic Civil (tabular) calendar. + /// Odd months have 30 days, even months have 29 days. + /// Month 12 has 30 days in leap years. + /// + private static int IslamicCivilDaysInMonth(int year, int month) + { + if (month <= 0 || month > 12) + { + return 0; + } + + // Odd months (1, 3, 5, 7, 9, 11) have 30 days + // Even months (2, 4, 6, 8, 10) have 29 days + // Month 12 has 30 days in leap years, 29 otherwise + if (month % 2 == 1) + { + return 30; + } + + if (month == 12 && IsIslamicCivilLeapYear(year)) + { + return 30; + } + + return 29; + } + + /// + /// Returns whether the given year is a leap year in the Islamic Civil calendar. + /// Uses the 30-year cycle: years 2, 5, 7, 10, 13, 16, 18, 21, 24, 26, 29 are leap years. + /// + private static bool IsIslamicCivilLeapYear(int year) + { + return (14 + 11 * year) % 30 < 11; + } + + /// + /// Converts an Islamic Civil (tabular) calendar date to ISO date via Julian Day Number. + /// The Islamic Civil calendar uses a fixed 30-year cycle with known leap year positions. + /// Epoch: July 16, 622 CE Julian (civil epoch, Friday). + /// + private static IsoDate? IslamicCivilDateToISO(int year, int month, int day, string overflow) + { + // Validate/constrain in the Islamic calendar system first + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + month = Clamp(month, 1, 12); + day = Clamp(day, 1, IslamicCivilDaysInMonth(year, month)); + } + else // "reject" + { + if (month < 1 || month > 12 || day < 1 || day > IslamicCivilDaysInMonth(year, month)) + { + return null; + } + } + + // Convert to Julian Day Number + // Formula from Calendrical Calculations (Reingold & Dershowitz) + var jdn = IslamicToJulianDay(year, month, day, 1948439L); + return JulianDayToISO(jdn); + } + + /// + /// Converts an Islamic Tabular (Thursday epoch) calendar date to ISO date via Julian Day Number. + /// Similar to Islamic Civil but with a different epoch (Thursday instead of Friday). + /// + private static IsoDate? IslamicTblaDateToISO(int year, int month, int day, string overflow) + { + // Same leap year and month structure as Islamic Civil + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + month = Clamp(month, 1, 12); + day = Clamp(day, 1, IslamicCivilDaysInMonth(year, month)); + } + else + { + if (month < 1 || month > 12 || day < 1 || day > IslamicCivilDaysInMonth(year, month)) + { + return null; + } + } - return null; // Unsupported calendar + // Islamic Tabular uses Thursday epoch (1 day earlier than Civil) + var jdn = IslamicToJulianDay(year, month, day, 1948438L); + return JulianDayToISO(jdn); + } + + /// + /// Computes the Julian Day Number for an Islamic calendar date with the given epoch. + /// + private static long IslamicToJulianDay(int year, int month, int day, long epoch) + { + var monthDays = (long) System.Math.Ceiling(29.5001 * (month - 1)); + var yearDays = (year - 1) * 354L + (long) System.Math.Floor((3.0 + 11.0 * year) / 30.0); + return monthDays + yearDays + day + epoch; + } + + /// + /// Converts a Julian Day Number to an ISO (proleptic Gregorian) date. + /// Uses the standard algorithm for JDN to Gregorian conversion. + /// + private static IsoDate? JulianDayToISO(long jdn) + { + // Algorithm from Meeus, "Astronomical Algorithms" + var a = jdn + 32044L; + var b = (4 * a + 3) / 146097; + var c = a - 146097 * b / 4; + var d = (4 * c + 3) / 1461; + var e = c - 1461 * d / 4; + var m = (5 * e + 2) / 153; + + var isoDay = (int) (e - (153 * m + 2) / 5 + 1); + var isoMonth = (int) (m + 3 - 12 * (m / 10)); + var isoYear = (int) (100 * b + d - 4800 + m / 10); + + if (!IsValidIsoDateTime(isoYear, isoMonth, isoDay)) + { + return null; + } + + return new IsoDate(isoYear, isoMonth, isoDay); + } + + /// + /// Returns true if the calendar uses the same Gregorian (proleptic) arithmetic as iso8601. + /// These calendars differ only in era/epoch, not in month/day/year calculations. + /// + internal static bool IsGregorianBasedCalendar(string calendar) + { + return calendar is "iso8601" or "gregory" or "japanese" or "roc" or "buddhist"; + } + + /// + /// Returns true if the calendar supports era/eraYear fields. + /// + internal static bool CalendarUsesEras(string calendar) + { + return calendar is "gregory" or "japanese" or "roc" or "buddhist" or "coptic" or "ethiopic" or "ethioaa" or "indian" + or "hebrew" or "islamic-civil" or "islamic-tbla" or "islamic-umalqura" or "persian"; + } + + /// + /// Returns the era string for a given calendar and ISO year, or null if not supported. + /// + internal static string? CalendarEra(string calendar, int isoYear) + { + switch (calendar) + { + case "gregory": + return isoYear >= 1 ? "ce" : "bce"; + case "roc": + return isoYear >= 1912 ? "minguo" : "before-roc"; + case "buddhist": + return "be"; + case "japanese": + // Simplified: use CE/BCE for years outside specific eras + if (isoYear >= 2019) return "reiwa"; + if (isoYear >= 1989) return "heisei"; + if (isoYear >= 1926) return "showa"; + if (isoYear >= 1912) return "taisho"; + if (isoYear >= 1868) return "meiji"; + return isoYear >= 1 ? "ce" : "bce"; + default: + return null; + } + } + + /// + /// Returns the eraYear for a given calendar and ISO year, or null if not supported. + /// + internal static int? CalendarEraYear(string calendar, int isoYear) + { + switch (calendar) + { + case "gregory": + return isoYear >= 1 ? isoYear : 1 - isoYear; + case "roc": + return isoYear >= 1912 ? isoYear - 1911 : 1912 - isoYear; + case "buddhist": + return isoYear + 543; + case "japanese": + if (isoYear >= 2019) return isoYear - 2018; + if (isoYear >= 1989) return isoYear - 1988; + if (isoYear >= 1926) return isoYear - 1925; + if (isoYear >= 1912) return isoYear - 1911; + if (isoYear >= 1868) return isoYear - 1867; + return isoYear >= 1 ? isoYear : 1 - isoYear; + default: + return null; + } + } + + /// + /// Returns the calendar-specific year for Gregorian-based calendars. + /// For non-Gregorian-based calendars, returns the ISO year unchanged (best effort). + /// + internal static int CalendarYear(string calendar, int isoYear) + { + switch (calendar) + { + case "gregory": + return isoYear; + case "roc": + return isoYear - 1911; + case "buddhist": + return isoYear + 543; + case "japanese": + return isoYear; // Japanese year getter returns ISO year + default: + return isoYear; + } + } + + /// + /// Reads era and eraYear properties from a property bag for era-supporting calendars. + /// Returns the computed year if era/eraYear are present, or null if they should be ignored. + /// Throws TypeError if only one of era/eraYear is present. + /// Throws RangeError if eraYear is invalid (Infinity, NaN, etc.). + /// + internal static int? ReadEraFields(Realm realm, ObjectInstance obj, string calendar) + { + if (!CalendarUsesEras(calendar)) + { + // For calendars that don't use eras, era/eraYear are not read at all + return null; + } + + var eraValue = obj.Get("era"); + var eraYearValue = obj.Get("eraYear"); + + var hasEra = !eraValue.IsUndefined(); + var hasEraYear = !eraYearValue.IsUndefined(); + + if (hasEra && hasEraYear) + { + // Convert era to string + var era = TypeConverter.ToString(eraValue); + // Convert eraYear to integer (this throws RangeError for Infinity/NaN) + var eraYear = ToIntegerWithTruncationAsInt(realm, eraYearValue); + + // Compute year from era + eraYear using calendar-specific era mapping + return ComputeYearFromEra(realm, calendar, era, eraYear); + } + + if (hasEra || hasEraYear) + { + // Only one of era/eraYear is present - this is an error + Throw.TypeError(realm, "Both era and eraYear must be provided together"); + } + + // Neither era nor eraYear present + return null; + } + + /// + /// Computes the ISO year from a calendar-specific era and eraYear. + /// Throws RangeError if the era is not valid for the given calendar. + /// + private static int ComputeYearFromEra(Realm realm, string calendar, string era, int eraYear) + { + switch (calendar) + { + case "gregory": + if (era is "gregory" or "ce" or "ad") + return eraYear; + if (era is "gregory-inverse" or "bce" or "bc") + return 1 - eraYear; + break; + case "japanese": + if (era is "reiwa" or "heisei" or "showa" or "taisho" or "meiji" or "ce" or "bce") + return eraYear; // simplified + break; + case "roc": + if (era is "minguo" or "roc") + return eraYear; + if (era is "before-roc" or "before-roc-inverse") + return 1 - eraYear; + break; + case "buddhist": + if (era is "buddhist" or "be") + return eraYear; + break; + case "coptic": + if (era is "coptic" or "era1") + return eraYear; + if (era is "coptic-inverse" or "era0") + return 1 - eraYear; + break; + case "ethiopic": + if (era is "ethiopic" or "incar" or "era1") + return eraYear; + if (era is "ethiopic-inverse" or "era0") + return 1 - eraYear; + if (era is "ethioaa" or "mundi") + return eraYear; // different epoch + break; + case "ethioaa": + if (era is "ethioaa" or "mundi") + return eraYear; + break; + case "indian": + if (era is "saka" or "indian") + return eraYear; + break; + case "hebrew": + if (era is "am" or "hebrew") + return eraYear; + break; + case "islamic-civil": + case "islamic-tbla": + case "islamic-umalqura": + if (era is "islamic" or "ah") + return eraYear; + break; + case "persian": + if (era is "persian" or "ap") + return eraYear; + break; + } + + Throw.RangeError(realm, $"Invalid era '{era}' for calendar '{calendar}'"); + return 0; } /// @@ -1623,7 +2089,7 @@ internal static bool LooksLikeIsoDateString(string input) /// Extracts the calendar annotation from an ISO date/datetime string. /// Returns the calendar ID if found and valid, empty string if found but invalid, null if not found. /// - private static string? ExtractCalendarFromIsoString(string input) + internal static string? ExtractCalendarFromIsoString(string input) { // Look for [u-ca=...] annotation var bracketStart = input.IndexOf('['); @@ -1643,13 +2109,9 @@ internal static bool LooksLikeIsoDateString(string input) var calendarStart = ucaIndex + 5; var calendarValue = annotation.Substring(calendarStart); var lower = ToAsciiLowerCase(calendarValue); - if (string.Equals(lower, "iso8601", StringComparison.Ordinal)) - { - return "iso8601"; - } - // Other calendars not supported - return empty string to indicate "found but invalid" - return string.Empty; + // Return the extracted calendar value for canonicalization + return lower; } bracketStart = input.IndexOf('[', bracketEnd); @@ -1658,6 +2120,24 @@ internal static bool LooksLikeIsoDateString(string input) return null; // No calendar annotation found } + /// + /// Extracts and canonicalizes the calendar from a string, defaulting to "iso8601". + /// + internal static string ExtractCalendarIdentifierFromString(string input) + { + var extracted = ExtractCalendarFromIsoString(input); + if (extracted is not null) + { + var canonical = CanonicalizeCalendar(extracted); + if (canonical is not null) + { + return canonical; + } + } + + return "iso8601"; + } + /// /// Converts a string to ASCII lower case (not Turkish İ). /// @@ -1730,8 +2210,8 @@ public static IsoYearMonthRecord BalanceISOYearMonth(int year, int month) /// public static IsoDate CalendarDateAdd(Realm? realm, string calendar, IsoDate isoDate, DurationRecord duration, string overflow) { - // For ISO calendar - if (string.Equals(calendar, "iso8601", StringComparison.Ordinal)) + // For ISO calendar and Gregorian-based calendars (they share the same arithmetic) + if (IsGregorianBasedCalendar(calendar)) { // Step 1: Add years and months, then balance var balanced = BalanceISOYearMonth( @@ -3498,13 +3978,16 @@ private static RelativeToFields PrepareCalendarFieldsForRelativeTo( } // Step 2: Read all other fields in alphabetical order - // Property keys in alphabetical order: day, hour, microsecond, millisecond, minute, + // Property keys in alphabetical order: day, era, eraYear, hour, microsecond, millisecond, minute, // month, monthCode, nanosecond, offset, second, timeZone, year // day - to-positive-integer-with-truncation var dayValue = fields.Get("day"); int? day = dayValue.IsUndefined() ? null : ToPositiveIntegerWithTruncation(realm, dayValue); + // era/eraYear - read for era-supporting calendars (alphabetically between day and hour) + var eraYear = ReadEraFields(realm, fields, calendar); + // hour - to-integer-with-truncation var hourValue = fields.Get("hour"); int? hour = hourValue.IsUndefined() @@ -3597,11 +4080,20 @@ private static RelativeToFields PrepareCalendarFieldsForRelativeTo( engine, realm, timeZoneValue); } - // year - to-integer-with-truncation - var yearValue = fields.Get("year"); - int? year = yearValue.IsUndefined() - ? null - : (int) ToIntegerWithTruncation(realm, yearValue); + // year - use eraYear if computed, otherwise read from property + int? year; + if (eraYear.HasValue) + { + year = eraYear.Value; + fields.Get("year"); + } + else + { + var yearValue = fields.Get("year"); + year = yearValue.IsUndefined() + ? null + : (int) ToIntegerWithTruncation(realm, yearValue); + } return new RelativeToFields(calendar, day, hour, microsecond, millisecond, minute, month, monthCode, nanosecond, offset, second, timeZone, year); @@ -4579,6 +5071,69 @@ public static BigInteger GetEpochNanosecondsFor( return DisambiguatePossibleEpochNanoseconds(realm, timeZoneProvider, possibleEpochNs, timeZone, isoDateTime, disambiguation); } + /// + /// GetStartOfDay ( timeZone, isoDate ) + /// https://tc39.es/proposal-temporal/#sec-temporal-getstartofday + /// Determines the exact time that corresponds to the first valid wall-clock time on the given date in the given timezone. + /// + public static BigInteger GetStartOfDay( + Realm realm, + ITimeZoneProvider provider, + string timeZone, + IsoDate isoDate) + { + // Step 1: Let isoDateTime be CombineISODateAndTimeRecord(isoDate, MidnightTimeRecord()) + var midnight = new IsoDateTime(isoDate, new IsoTime(0, 0, 0, 0, 0, 0)); + + // Step 2: Let possibleEpochNs be GetPossibleEpochNanoseconds(timeZone, isoDateTime) + var possibleEpochNs = GetPossibleEpochNanoseconds(realm, provider, timeZone, midnight); + + // Step 3: If possibleEpochNs is not empty, return possibleEpochNs[0] + if (possibleEpochNs.Length > 0) + { + return possibleEpochNs[0]; + } + + // Step 5: Midnight is in a DST gap. Find the first valid local time after midnight. + // The transition instant that creates the gap IS the start of day. + // Search for the transition near UTC midnight for this date. + var utcMidnightNs = (BigInteger) IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day) * NanosecondsPerDay; + + // Search from 24 hours before UTC midnight to cover all possible timezone offsets (max ±14h) + var searchFrom = utcMidnightNs - NanosecondsPerDay; + var transition = provider.GetNextTransition(timeZone, searchFrom); + + // Find the transition whose new local time lands on our target date + while (transition.HasValue && transition.Value < utcMidnightNs + NanosecondsPerDay) + { + // Get the offset AFTER the transition + var offsetAfter = provider.GetOffsetNanosecondsFor(timeZone, transition.Value); + var localNsAfter = transition.Value + offsetAfter; + + // Check if the local time after transition is on our target date and >= 00:00 + var localDays = localNsAfter / NanosecondsPerDay; + var localTimeNs = localNsAfter - localDays * NanosecondsPerDay; + if (localTimeNs < 0) + { + localDays--; + localTimeNs += NanosecondsPerDay; + } + + var targetDays = (BigInteger) IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day); + if (localDays == targetDays) + { + return transition.Value; + } + + // Move to next transition + transition = provider.GetNextTransition(timeZone, transition.Value); + } + + // Fallback: should not reach here for valid timezones + Throw.RangeError(realm, "Could not determine start of day"); + return BigInteger.Zero; + } + /// /// GetISODateTimeFor ( timeZone, epochNanoseconds ) /// Converts epoch nanoseconds to an ISO date-time in the given timezone. @@ -4694,7 +5249,13 @@ private static DurationRecord DifferenceZonedDateTime( var intermediateDateTime = new IsoDateTime(intermediateDate, startDateTime.Time); // Step 6: Get epoch nanoseconds for intermediate - var intermediateNs = GetEpochNanosecondsFor(realm, timeZoneProvider, timeZone, intermediateDateTime, "compatible"); + // Per spec (issue #3141): when the intermediate datetime equals the start datetime, + // use ns1 directly to avoid re-disambiguation during DST fall-back + var intermediateNs = (intermediateDate.Year == startDateTime.Year && + intermediateDate.Month == startDateTime.Month && + intermediateDate.Day == startDateTime.Day) + ? ns1 + : GetEpochNanosecondsFor(realm, timeZoneProvider, timeZone, intermediateDateTime, "compatible"); // Step 7: Compute time difference from actual epoch nanoseconds var timeDifference = TimeDurationFromEpochNanosecondsDifference(ns2, intermediateNs); @@ -5703,8 +6264,8 @@ internal static DurationRecord CalendarDateUntil(string calendar, IsoDate one, I return new DurationRecord(0, 0, 0, 0, 0, 0, 0, 0, 0, 0); } - // Step 3: For ISO calendar - if (!string.Equals(calendar, "iso8601", StringComparison.Ordinal)) + // Step 3: For ISO and Gregorian-based calendars (same arithmetic) + if (!IsGregorianBasedCalendar(calendar)) { throw new NotSupportedException($"Calendar '{calendar}' not yet supported"); } diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs index 5197138e8a..167fdd3afe 100644 --- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs +++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs @@ -203,6 +203,9 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj, var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayValue); + // 2.5. era/eraYear - read for era-supporting calendars (alphabetically between day and hour) + var eraYear = TemporalHelpers.ReadEraFields(_realm, obj, calendar); + // 3. hour - read and convert immediately var hourValue = obj.Get("hour"); var hour = hourValue.IsUndefined() ? 0 : TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, hourValue); @@ -302,14 +305,23 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj, var timeZone = ToTemporalTimeZoneIdentifier(timeZoneProp); - // 13. year - read and convert immediately (TYPE validation happens here) - var yearValue = obj.Get("year"); - if (yearValue.IsUndefined()) + // 13. year - use eraYear if computed, otherwise read from property + int year; + if (eraYear.HasValue) { - Throw.TypeError(_realm, "Missing required property: year"); + year = eraYear.Value; + obj.Get("year"); } + else + { + var yearValue = obj.Get("year"); + if (yearValue.IsUndefined()) + { + Throw.TypeError(_realm, "Missing required property: year"); + } - var year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearValue); + } // 14-16. Read options AFTER all fields(but BEFORE algorithmic validation) // Alphabetical order: disambiguation, offset, overflow @@ -317,8 +329,8 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj, var offsetOption = GetOffsetOption(options); var overflow = TemporalHelpers.GetOverflowOption(_realm, options); - // NOW validate monthCode suitability(ISO calendar checks) - after year type validation AND options reading - if (monthCodeStr is not null) + // Validate monthCode suitability - only for ISO/Gregorian calendars + if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar)) { // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') @@ -337,8 +349,8 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj, Throw.TypeError(_realm, "month or monthCode is required"); } - // Regulate date - var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); + // Regulate date (with calendar conversion for non-ISO calendars) + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); if (date is null) { Throw.RangeError(_realm, "Invalid date"); @@ -418,10 +430,56 @@ private JsZonedDateTime ParseZonedDateTimeString(string input, string overflow, var timeZone = ToTemporalTimeZoneIdentifier(new JsString(result.TimeZone)); - // Convert to epoch nanoseconds - var epochNs = GetEpochFromIsoDateTime(result.DateTime.Value, timeZone, disambiguation, result.OffsetNanoseconds, result.HasUtcDesignator, offsetOption); + // Per spec ToTemporalZonedDateTime steps 15-21: + // Determine offsetBehaviour + string offsetBehaviour; + if (result.HasUtcDesignator) + { + offsetBehaviour = "exact"; + } + else if (result.OffsetNanoseconds.HasValue) + { + offsetBehaviour = "option"; + } + else + { + offsetBehaviour = "wall"; + } + + // Per spec step 3/12: For strings, matchBehaviour = ~match-minutes~ + // Unless offset has sub-minute precision (seconds), then ~match-exactly~ + var matchBehaviour = result.OffsetHasSubMinutePrecision ? "match-exactly" : "match-minutes"; - return Construct(epochNs, timeZone, result.Calendar ?? "iso8601"); + var offsetNs = result.OffsetNanoseconds ?? 0; + + var provider = _engine.Options.Temporal.TimeZoneProvider; + var isoDateTime = result.DateTime.Value; + + BigInteger epochNs; + if (result.TimeIsStartOfDay) + { + // Per spec InterpretISODateTimeOffset step 1: time is ~start-of-day~ + epochNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, isoDateTime.Date); + } + else + { + // Convert to epoch nanoseconds using the spec's InterpretISODateTimeOffset + epochNs = TemporalHelpers.InterpretISODateTimeOffset( + _realm, + provider, + isoDateTime.Date, + isoDateTime.Time, + offsetBehaviour, + offsetNs, + timeZone, + disambiguation, + offsetOption, + matchBehaviour); + } + + var calendarId = result.Calendar ?? "iso8601"; + var canonicalCalendar = TemporalHelpers.CanonicalizeCalendar(calendarId) ?? calendarId; + return Construct(epochNs, timeZone, canonicalCalendar); } private BigInteger GetEpochFromIsoDateTime(IsoDateTime dateTime, string timeZone, string disambiguation, long? offsetNs, bool hasUtcDesignator, string offsetOption) @@ -549,89 +607,7 @@ private BigInteger GetEpochFromIsoDateTime(IsoDateTime dateTime, string timeZone private BigInteger GetInstantFor(ITimeZoneProvider provider, string timeZone, IsoDateTime dateTime, string disambiguation) { - var possibleInstants = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - dateTime.Hour, dateTime.Minute, dateTime.Second, - dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - - // Per spec GetPossibleEpochNanoseconds, validate each possible instant is within valid range - // https://tc39.es/proposal-temporal/#sec-temporal-getpossibleepochnanoseconds step 4 - foreach (var instant in possibleInstants) - { - if (!InstantConstructor.IsValidEpochNanoseconds(instant)) - { - Throw.RangeError(_realm, "Instant is outside the valid range"); - } - } - - if (possibleInstants.Length == 1) - { - return possibleInstants[0]; - } - - if (possibleInstants.Length == 0) - { - // Gap (spring forward) - time doesn't exist - if (string.Equals(disambiguation, "reject", StringComparison.Ordinal)) - { - Throw.RangeError(_realm, "The specified date-time does not exist in the time zone (gap)"); - } - - // Find the transition point - var localNs = IsoDateTimeToNanoseconds(dateTime); - - if (string.Equals(disambiguation, "earlier", StringComparison.Ordinal)) - { - // Use the instant just before the gap - // Subtract one nanosecond from where the gap starts - var beforeGap = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - dateTime.Hour - 1, dateTime.Minute, dateTime.Second, - dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - if (beforeGap.Length > 0) - { - return beforeGap[beforeGap.Length - 1]; - } - } - - // Default to 'compatible' or 'later' - use the instant after the gap - // The gap is typically 1 hour, so add 1 hour to the local time - var afterGapDateTime = AddHoursToIsoDateTime(dateTime, 1); - var afterGapInstants = provider.GetPossibleInstantsFor(timeZone, - afterGapDateTime.Year, afterGapDateTime.Month, afterGapDateTime.Day, - afterGapDateTime.Hour, afterGapDateTime.Minute, afterGapDateTime.Second, - afterGapDateTime.Millisecond, afterGapDateTime.Microsecond, afterGapDateTime.Nanosecond); - - if (afterGapInstants.Length > 0) - { - return afterGapInstants[0]; - } - - // Fallback: estimate the instant - var standardOffset = provider.GetOffsetNanosecondsFor(timeZone, BigInteger.Zero); - return localNs - standardOffset; - } - - // Ambiguous (fall back) - multiple possible instants - if (string.Equals(disambiguation, "reject", StringComparison.Ordinal)) - { - Throw.RangeError(_realm, "The specified date-time is ambiguous in the time zone (overlap)"); - } - - if (string.Equals(disambiguation, "earlier", StringComparison.Ordinal)) - { - // Use the earlier instant (before the clocks were set back) - return possibleInstants[0]; - } - - if (string.Equals(disambiguation, "later", StringComparison.Ordinal)) - { - // Use the later instant (after the clocks were set back) - return possibleInstants[possibleInstants.Length - 1]; - } - - // Default 'compatible' - use the earlier instant (same as 'earlier') - return possibleInstants[0]; + return TemporalHelpers.GetEpochNanosecondsFor(_realm, provider, timeZone, dateTime, disambiguation); } private static IsoDateTime AddHoursToIsoDateTime(IsoDateTime dateTime, int hours) @@ -697,7 +673,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string var dateResult = TemporalHelpers.ParseIsoDate(datePart); if (dateResult is null) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid date"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid date"); } // Default time to 00:00:00 @@ -740,15 +716,15 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string // Validate: No junk after the last bracket annotation if (lastBracketEnd >= 0 && lastBracketEnd + 1 < input.Length) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Unexpected characters after valid content"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Unexpected characters after valid content"); } if (parsedTimeZone is null) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "ZonedDateTime string must include timezone annotation"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "ZonedDateTime string must include timezone annotation"); } - return new ParsedZonedDateTimeResult(parsedDateTime, parsedTimeZone, parsedCalendar, null, false, null); + return new ParsedZonedDateTimeResult(parsedDateTime, parsedTimeZone, parsedCalendar, null, false, false, true, null); } var dateString = input.Substring(0, tIndex); @@ -757,7 +733,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string var date = TemporalHelpers.ParseIsoDate(dateString); if (date is null) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid date"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid date"); } // Parse time and extract offset/time zone/calendar @@ -767,6 +743,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string string? timeZone = null; string? calendar = null; long? offsetNs = null; + var offsetHasSubMinutePrecision = false; // Find where time ends for (var i = 0; i < remainder.Length; i++) @@ -801,7 +778,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string var time = TemporalHelpers.ParseIsoTime(timeString); if (time is null) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid time"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid time"); } // Parse offset if present @@ -827,8 +804,14 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string offsetNs = TemporalHelpers.ParseOffsetString(offsetStr); if (offsetNs is null) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Invalid offset"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Invalid offset"); } + + // Per spec: detect sub-minute precision in offset string + // UTCOffset[+SubMinutePrecision] with more than one MinuteSecond means it has seconds + // Format: ±HH:MM (6 chars) = minute precision, ±HH:MM:SS or longer = sub-minute precision + // Also handle ±HHMM (5 chars) vs ±HHMMSS (7 chars) + offsetHasSubMinutePrecision = HasOffsetSubMinutePrecision(offsetStr); } // Parse bracket annotations @@ -844,7 +827,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string var endBracket = remainder.IndexOf(']', pos); if (endBracket < 0) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Unclosed bracket"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Unclosed bracket"); } var annotation = remainder.Substring(pos + 1, endBracket - pos - 1); @@ -859,7 +842,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string var key = content.Substring(0, equalsIndex); if (!TemporalHelpers.IsLowercaseAnnotationKey(key)) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Annotation keys must be lowercase"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Annotation keys must be lowercase"); } if (content.StartsWith("u-ca=", StringComparison.Ordinal)) @@ -879,7 +862,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string else if (isCritical) { // Unknown key annotation with critical flag - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Critical unknown annotation"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Critical unknown annotation"); } // Non-critical unknown annotations are accepted but ignored } @@ -889,7 +872,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string timeZoneCount++; if (timeZoneCount > 1) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Multiple time zone annotations"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Multiple time zone annotations"); } timeZone = content; @@ -901,19 +884,19 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string // Validate: Multiple calendar annotations with any critical flag is invalid if (calendarCount > 1 && hasCriticalCalendar) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Multiple calendar annotations with critical flag"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Multiple calendar annotations with critical flag"); } // Validate: No junk after the last bracket annotation if (pos < remainder.Length) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "Unexpected characters after valid content"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "Unexpected characters after valid content"); } // ZonedDateTime requires a time zone if (timeZone is null) { - return new ParsedZonedDateTimeResult(null, null, null, null, false, "ZonedDateTime string must include a time zone"); + return new ParsedZonedDateTimeResult(null, null, null, null, false, false, false, "ZonedDateTime string must include a time zone"); } var dateTime = new IsoDateTime(date.Value, time.Value); @@ -923,7 +906,7 @@ private static ParsedZonedDateTimeResult ParseZonedDateTimeStringInternal(string // For offset="use", only the resulting instant needs to be valid // For offset="prefer"/"reject", the wall-clock datetime needs to be valid (checked via CheckISODaysRange) - return new ParsedZonedDateTimeResult(dateTime, timeZone, calendar, offsetNs, hasUtcDesignator, null); + return new ParsedZonedDateTimeResult(dateTime, timeZone, calendar, offsetNs, hasUtcDesignator, offsetHasSubMinutePrecision, false, null); } /// @@ -944,12 +927,15 @@ private string ParseConstructorTimeZone(string input) // If the whole string is just a timezone identifier (IANA name or UTC offset), accept it var bracketStart = input.IndexOf('['); var hasDateTimeSeparator = false; - for (var i = 4; i < input.Length; i++) + if (input.Length > 4 && char.IsDigit(input[0])) { - if (input[i] == 'T' || input[i] == 't') + for (var i = 4; i < input.Length; i++) { - hasDateTimeSeparator = true; - break; + if (input[i] == 'T' || input[i] == 't') + { + hasDateTimeSeparator = true; + break; + } } } @@ -1088,7 +1074,26 @@ private string GetOffsetOption(JsValue options) } /// - /// Checks if an annotation key is valid (lowercase letters, digits, and hyphens only). + /// Per spec: determines if an offset string has sub-minute precision (contains seconds). + /// Used to set matchBehaviour to ~match-exactly~ vs ~match-minutes~. + /// https://tc39.es/proposal-temporal/#sec-temporal-totemporalzoneddatetime step 23-24 /// - private readonly record struct ParsedZonedDateTimeResult(IsoDateTime? DateTime, string? TimeZone, string? Calendar, long? OffsetNanoseconds, bool HasUtcDesignator, string? Error); + private static bool HasOffsetSubMinutePrecision(string offsetStr) + { + // ±HH (3 chars) or ±HH:MM (6 chars) or ±HHMM (5 chars) = minute precision + // ±HH:MM:SS (9+ chars) or ±HHMMSS (7 chars) = sub-minute precision + if (offsetStr.Length <= 3) + return false; // ±HH + + if (offsetStr[3] == ':') + { + // Colon format: ±HH:MM = 6 chars, ±HH:MM:SS = 9+ chars + return offsetStr.Length > 6; + } + + // No-colon format: ±HHMM = 5 chars, ±HHMMSS = 7 chars + return offsetStr.Length > 5; + } + + private readonly record struct ParsedZonedDateTimeResult(IsoDateTime? DateTime, string? TimeZone, string? Calendar, long? OffsetNanoseconds, bool HasUtcDesignator, bool OffsetHasSubMinutePrecision, bool TimeIsStartOfDay, string? Error); } diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs index 3ee33abf3d..cbc126d1e3 100644 --- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs +++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs @@ -111,20 +111,26 @@ private JsString GetTimeZoneId(JsValue thisObject, JsCallArguments arguments) => private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { - ValidateZonedDateTime(thisObject); - // ISO 8601 calendar doesn't have eras - return Undefined; + var zdt = ValidateZonedDateTime(thisObject); + var isoDateTime = zdt.GetIsoDateTime(); + var era = TemporalHelpers.CalendarEra(zdt.Calendar, isoDateTime.Year); + return era is not null ? new JsString(era) : Undefined; } private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { - ValidateZonedDateTime(thisObject); - // ISO 8601 calendar doesn't have eras - return Undefined; + var zdt = ValidateZonedDateTime(thisObject); + var isoDateTime = zdt.GetIsoDateTime(); + var eraYear = TemporalHelpers.CalendarEraYear(zdt.Calendar, isoDateTime.Year); + return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } - private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) => - JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Year); + private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) + { + var zdt = ValidateZonedDateTime(thisObject); + var isoDateTime = zdt.GetIsoDateTime(); + return JsNumber.Create(TemporalHelpers.CalendarYear(zdt.Calendar, isoDateTime.Year)); + } private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Month); @@ -182,16 +188,26 @@ private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) => private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Date.DayOfYear()); - private JsNumber GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) + private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); + if (!string.Equals(zdt.Calendar, "iso8601", StringComparison.Ordinal)) + { + return Undefined; + } + var date = zdt.GetIsoDateTime().Date; return JsNumber.Create(date.WeekOfYear()); } - private JsNumber GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) + private JsValue GetYearOfWeek(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); + if (!string.Equals(zdt.Calendar, "iso8601", StringComparison.Ordinal)) + { + return Undefined; + } + var date = zdt.GetIsoDateTime().Date; return JsNumber.Create(date.YearOfWeek()); } @@ -243,11 +259,9 @@ private JsNumber GetHoursInDay(JsValue thisObject, JsCallArguments arguments) Throw.RangeError(_realm, "Start of day is outside the valid range"); } - // Get start of next day + // Get start of next day using GetStartOfDay per spec var nextDay = AddDays(zdt.GetIsoDateTime().Date, 1); - var nextDayInstants = provider.GetPossibleInstantsFor(zdt.TimeZone, - nextDay.Year, nextDay.Month, nextDay.Day, 0, 0, 0, 0, 0, 0); - var startOfNextDay = nextDayInstants.Length > 0 ? nextDayInstants[0] : startOfDay + TemporalHelpers.NanosecondsPerDay; + var startOfNextDay = TemporalHelpers.GetStartOfDay(_realm, provider, zdt.TimeZone, nextDay); if (!InstantConstructor.IsValidEpochNanoseconds(startOfNextDay)) { @@ -479,25 +493,24 @@ private JsZonedDateTime WithPlainTime(JsValue thisObject, JsCallArguments argume // https://tc39.es/proposal-temporal/#sec-temporal.zoneddatetime.prototype.withplaintime var zdt = ValidateZonedDateTime(thisObject); var plainTimeLike = arguments.At(0); + var provider = _engine.Options.Temporal.TimeZoneProvider; - IsoTime time; + BigInteger epochNs; if (plainTimeLike.IsUndefined()) { - // Step 6: If plainTimeLike is undefined, let plainTime be start of day - time = new IsoTime(0, 0, 0, 0, 0, 0); + // Step 7: If plainTimeLike is undefined, let epochNs be GetStartOfDay + var current = zdt.GetIsoDateTime(); + epochNs = TemporalHelpers.GetStartOfDay(_realm, provider, zdt.TimeZone, current.Date); } else { - // Step 8: Let plainTime be ? ToTemporalTime(plainTimeLike) + // Step 8-9: Let plainTime be ToTemporalTime, then GetEpochNanosecondsFor with compatible var plainTime = _realm.Intrinsics.TemporalPlainTime.ToTemporalTime(plainTimeLike, "constrain"); - time = plainTime.IsoTime; + var current = zdt.GetIsoDateTime(); + var newDateTime = new IsoDateTime(current.Date, plainTime.IsoTime); + epochNs = GetInstantFor(provider, zdt.TimeZone, newDateTime, "compatible"); } - var current = zdt.GetIsoDateTime(); - var newDateTime = new IsoDateTime(current.Date, time); - var provider = _engine.Options.Temporal.TimeZoneProvider; - var epochNs = GetInstantFor(provider, zdt.TimeZone, newDateTime, "compatible"); - // Validate result is within valid Temporal range if (!InstantConstructor.IsValidEpochNanoseconds(epochNs)) { @@ -674,25 +687,9 @@ private JsValue Round(JsValue thisObject, JsCallArguments arguments) var dateStart = isoDateTime.Date; var dateEnd = AddDays(dateStart, 1); - // Get start of current day in this timezone - var startInstants = provider.GetPossibleInstantsFor(timeZone, - dateStart.Year, dateStart.Month, dateStart.Day, 0, 0, 0, 0, 0, 0); - var startNs = startInstants.Length > 0 ? startInstants[0] : thisNs - (BigInteger) isoDateTime.Time.TotalNanoseconds(); - - if (!InstantConstructor.IsValidEpochNanoseconds(startNs)) - { - Throw.RangeError(_realm, "Start of day is outside the valid range"); - } - - // Get start of next day in this timezone - var endInstants = provider.GetPossibleInstantsFor(timeZone, - dateEnd.Year, dateEnd.Month, dateEnd.Day, 0, 0, 0, 0, 0, 0); - var endNs = endInstants.Length > 0 ? endInstants[0] : startNs + TemporalHelpers.NanosecondsPerDay; - - if (!InstantConstructor.IsValidEpochNanoseconds(endNs)) - { - Throw.RangeError(_realm, "Start of next day is outside the valid range"); - } + // Get start of current day and next day using GetStartOfDay per spec + var startNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, dateStart); + var endNs = TemporalHelpers.GetStartOfDay(_realm, provider, timeZone, dateEnd); // Calculate day length (may not be exactly 24 hours due to DST) var dayLengthNs = (long) (endNs - startNs); @@ -863,7 +860,7 @@ private JsBoolean Equals(JsValue thisObject, JsCallArguments arguments) var otherZdt = _constructor.ToTemporalZonedDateTime(other, Undefined); var sameEpoch = zdt.EpochNanoseconds == otherZdt.EpochNanoseconds; - var sameTimeZone = string.Equals(zdt.TimeZone, otherZdt.TimeZone, StringComparison.OrdinalIgnoreCase); + var sameTimeZone = TemporalHelpers.TimeZoneEquals(_engine, zdt.TimeZone, otherZdt.TimeZone); var sameCalendar = string.Equals(zdt.Calendar, otherZdt.Calendar, StringComparison.Ordinal); return sameEpoch && sameTimeZone && sameCalendar ? JsBoolean.True : JsBoolean.False; @@ -1071,10 +1068,39 @@ private JsString ToJSON(JsValue thisObject, JsCallArguments arguments) return new JsString(FormatZonedDateTime(zdt, "auto", "auto", "auto", null)); } - private JsString ToLocaleString(JsValue thisObject, JsCallArguments arguments) + /// + /// https://tc39.es/proposal-temporal/#sup-temporal.zoneddatetime.prototype.tolocalestring + /// + private JsValue ToLocaleString(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); - return new JsString(FormatZonedDateTime(zdt, "auto", "auto", "auto", null)); + var locales = arguments.At(0); + var options = arguments.At(1); + + // Per spec: CreateDateTimeFormat with required=~any~, defaults=~all~, toLocaleStringTimeZone=ZDT.[[TimeZone]] + // Uses ZonedDateTime defaults which include timeZoneName: "short" + // This will throw TypeError if user passes a timeZone option + var dtf = _realm.Intrinsics.DateTimeFormat.CreateDateTimeFormat( + locales, options, required: Intl.DateTimeRequired.Any, defaults: Intl.DateTimeDefaults.ZonedDateTime, + toLocaleStringTimeZone: zdt.TimeZone); + + // Calendar mismatch check per spec + var cal = zdt.Calendar; + if (!string.Equals(cal, "iso8601", StringComparison.Ordinal) && + dtf.Calendar != null && !string.Equals(cal, dtf.Calendar, StringComparison.Ordinal)) + { + Throw.RangeError(_realm, $"Calendar mismatch: ZonedDateTime uses '{cal}' but DateTimeFormat uses '{dtf.Calendar}'"); + } + + // Per spec: create instant from ZDT's epoch nanoseconds and format it + // The DTF was created with ZDT's timezone, so the instant will be formatted in that timezone + const long nsPerTick = 100; + var ticks = (long) (zdt.EpochNanoseconds / nsPerTick); + var unixEpochTicks = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + var totalTicks = unixEpochTicks + ticks; + var dateTime = new DateTime(totalTicks, DateTimeKind.Utc); + + return dtf.Format(dateTime); } private JsValue ValueOf(JsValue thisObject, JsCallArguments arguments) @@ -1103,7 +1129,7 @@ private static string FormatZonedDateTime(JsZonedDateTime zdt, string showCalend // Add offset if (!string.Equals(showOffset, "never", StringComparison.Ordinal)) { - var offsetStr = TemporalHelpers.FormatOffsetString(zdt.OffsetNanoseconds); + var offsetStr = TemporalHelpers.FormatOffsetRounded(zdt.OffsetNanoseconds); result += offsetStr; } @@ -1126,29 +1152,10 @@ private static string FormatZonedDateTime(JsZonedDateTime zdt, string showCalend return result; } - private static BigInteger GetStartOfDayInstant(JsZonedDateTime zdt, ITimeZoneProvider provider) + private BigInteger GetStartOfDayInstant(JsZonedDateTime zdt, ITimeZoneProvider provider) { var dt = zdt.GetIsoDateTime(); - var possibleInstants = provider.GetPossibleInstantsFor(zdt.TimeZone, - dt.Year, dt.Month, dt.Day, 0, 0, 0, 0, 0, 0); - - if (possibleInstants.Length > 0) - { - return possibleInstants[0]; - } - - // Gap at midnight - find the instant after the gap - var startDateTime = new IsoDateTime(dt.Date, new IsoTime(1, 0, 0, 0, 0, 0)); - var afterGapInstants = provider.GetPossibleInstantsFor(zdt.TimeZone, - startDateTime.Year, startDateTime.Month, startDateTime.Day, - 1, 0, 0, 0, 0, 0); - - if (afterGapInstants.Length > 0) - { - return afterGapInstants[0] - TemporalHelpers.NanosecondsPerHour; - } - - return zdt.EpochNanoseconds - (BigInteger) dt.Time.TotalNanoseconds(); + return TemporalHelpers.GetStartOfDay(_realm, provider, zdt.TimeZone, dt.Date); } private static IsoDate AddDays(IsoDate date, int days) @@ -1292,7 +1299,7 @@ private JsDuration DifferenceZonedDateTime(JsZonedDateTime zonedDateTime, JsZone } // Step 12-13: For date units, timezones must match - if (!string.Equals(zonedDateTime.TimeZone, other.TimeZone, StringComparison.Ordinal)) + if (!TemporalHelpers.TimeZoneEquals(_engine, zonedDateTime.TimeZone, other.TimeZone)) { Throw.RangeError(_realm, "Time zones must match for ZonedDateTime difference with date units"); } @@ -1495,46 +1502,54 @@ private BigInteger GetEpochFromIsoDateTime(IsoDateTime dateTime, string timeZone return epochNs; } - return GetInstantFor(provider, timeZone, dateTime, disambiguation); - } - - private BigInteger GetInstantFor(ITimeZoneProvider provider, string timeZone, IsoDateTime dateTime, string disambiguation) - { - var possibleInstants = provider.GetPossibleInstantsFor(timeZone, - dateTime.Year, dateTime.Month, dateTime.Day, - dateTime.Hour, dateTime.Minute, dateTime.Second, - dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - - if (possibleInstants.Length == 1) + if (offsetNs.HasValue && string.Equals(offsetOption, "reject", StringComparison.Ordinal)) { - return possibleInstants[0]; - } + // In "reject" mode, the provided offset must exactly match the actual offset + var possibleInstants = provider.GetPossibleInstantsFor( + timeZone, + dateTime.Year, dateTime.Month, dateTime.Day, + dateTime.Hour, dateTime.Minute, dateTime.Second, + dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - if (possibleInstants.Length == 0) - { - // Gap - if (string.Equals(disambiguation, "reject", StringComparison.Ordinal)) + foreach (var instant in possibleInstants) { - Throw.RangeError(_realm, "The specified date-time does not exist in the time zone (gap)"); + var actualOffset = provider.GetOffsetNanosecondsFor(timeZone, instant); + if (actualOffset == offsetNs.Value) + { + return instant; + } } - var localNs = IsoDateTimeToNanoseconds(dateTime); - var standardOffset = provider.GetOffsetNanosecondsFor(timeZone, BigInteger.Zero); - return localNs - standardOffset; + Throw.RangeError(_realm, "Offset does not match any possible offset for this wall time"); } - // Ambiguous - if (string.Equals(disambiguation, "reject", StringComparison.Ordinal)) + if (offsetNs.HasValue && string.Equals(offsetOption, "prefer", StringComparison.Ordinal)) { - Throw.RangeError(_realm, "The specified date-time is ambiguous in the time zone (overlap)"); - } + // In "prefer" mode, use the provided offset if it matches, otherwise disambiguate + var possibleInstants = provider.GetPossibleInstantsFor( + timeZone, + dateTime.Year, dateTime.Month, dateTime.Day, + dateTime.Hour, dateTime.Minute, dateTime.Second, + dateTime.Millisecond, dateTime.Microsecond, dateTime.Nanosecond); - if (string.Equals(disambiguation, "later", StringComparison.Ordinal)) - { - return possibleInstants[possibleInstants.Length - 1]; + foreach (var instant in possibleInstants) + { + var actualOffset = provider.GetOffsetNanosecondsFor(timeZone, instant); + if (actualOffset == offsetNs.Value) + { + return instant; + } + } + + // No match - fall through to normal disambiguation } - return possibleInstants[0]; + return GetInstantFor(provider, timeZone, dateTime, disambiguation); + } + + private BigInteger GetInstantFor(ITimeZoneProvider provider, string timeZone, IsoDateTime dateTime, string disambiguation) + { + return TemporalHelpers.GetEpochNanosecondsFor(_realm, provider, timeZone, dateTime, disambiguation); } private static BigInteger IsoDateTimeToNanoseconds(IsoDateTime dateTime) diff --git a/Jint/Runtime/EventLoop.cs b/Jint/Runtime/EventLoop.cs index 2fddfffaf9..e2ea9f6e6a 100644 --- a/Jint/Runtime/EventLoop.cs +++ b/Jint/Runtime/EventLoop.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Threading; -using System.Threading.Tasks; namespace Jint.Runtime;