#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; } }