浏览代码

Create ITimeSystem abstraction for date parsing/time zone logic (#1488)

Marko Lahma 2 年之前
父节点
当前提交
56cc45ee9e

+ 1 - 0
Jint.Tests.PublicInterface/Jint.Tests.PublicInterface.csproj

@@ -26,6 +26,7 @@
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
     <PackageReference Include="MongoDB.Bson.signed" Version="2.14.1" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
+    <PackageReference Include="NodaTime" Version="3.1.6" />
     <PackageReference Include="xunit" Version="2.4.2" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
   </ItemGroup>

+ 54 - 0
Jint.Tests.PublicInterface/TimeSystemTests.cs

@@ -0,0 +1,54 @@
+using System.Globalization;
+using Jint.Runtime;
+using NodaTime;
+
+namespace Jint.Tests.PublicInterface;
+
+public class TimeSystemTests
+{
+    [Theory]
+    [InlineData("401, 0, 1, 0, 0, 0, 0", -49512821989000)]
+    [InlineData("1900, 0, 1, 0, 0, 0, 0", -2208994789000)]
+    [InlineData("1920, 0, 1, 0, 0, 0, 0", -1577929189000)]
+    [InlineData("1969, 0, 1, 0, 0, 0, 0", -31543200000)]
+    [InlineData("2000, 1, 1, 1, 1, 1, 1", 949359661001)]
+    public void CanProduceValidDatesUsingNodaTimeIntegration(string input, long expected)
+    {
+        var dateTimeZone = DateTimeZoneProviders.Tzdb["Europe/Helsinki"];
+        TimeZoneInfo timeZone;
+        try
+        {
+            timeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Helsinki");
+        }
+        catch (TimeZoneNotFoundException)
+        {
+            timeZone = TimeZoneInfo.FindSystemTimeZoneById("FLE Standard Time");
+        }
+
+        var engine = new Engine(options =>
+        {
+            options.TimeZone = timeZone;
+            options.TimeSystem = new NodaTimeSystem(dateTimeZone, timeZone);
+        });
+
+        Assert.Equal(expected, engine.Evaluate($"new Date({input}) * 1").AsNumber());
+    }
+}
+
+file sealed class NodaTimeSystem : DefaultTimeSystem
+{
+    private readonly DateTimeZone _dateTimeZone;
+
+    public NodaTimeSystem(
+        DateTimeZone dateTimeZone,
+        TimeZoneInfo timeZoneInfo) : base(timeZoneInfo, CultureInfo.CurrentCulture)
+    {
+        _dateTimeZone = dateTimeZone;
+    }
+
+    public override TimeSpan GetUtcOffset(long epochMilliseconds)
+    {
+        var offset = _dateTimeZone.GetUtcOffset(Instant.FromUnixTimeMilliseconds(epochMilliseconds));
+        return offset.ToTimeSpan();
+    }
+}

+ 0 - 6
Jint.Tests.Test262/Test262Harness.settings.json

@@ -95,9 +95,6 @@
     // Windows line ending differences
     "built-ins/String/raw/special-characters.js",
 
-    // parsing of large/small years not implemented in .NET (-271821, +271821)
-    "built-ins/Date/parse/time-value-maximum-range.js",
-
     // delete/add detection not implemented for map iterator during iteration
     "built-ins/Map/prototype/forEach/iterates-values-deleted-then-readded.js",
     "built-ins/MapIteratorPrototype/next/iteration-mutable.js",
@@ -183,9 +180,6 @@
     // special casing data
     "built-ins/**/special_casing*.js",
 
-    // negative years, we can partially handle but not values too big due to .NET parsing limitations
-    "built-ins/Date/prototype/*/negative-year.js",
-
     // failing tests in new test suite (due to updating to latest and using whole set)
     "language/arguments-object/mapped/nonconfigurable-descriptors-define-failure.js",
     "language/eval-code/direct/arrow-fn-a-following-parameter-is-named-arguments-arrow-func-declare-arguments-assign-incl-def-param-arrow-arguments.js",

+ 0 - 1
Jint.Tests/Jint.Tests.csproj

@@ -30,7 +30,6 @@
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
     <PackageReference Include="MongoDB.Bson.signed" Version="2.14.1" />
     <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
-    <PackageReference Include="NodaTime" Version="3.1.6" />
     <PackageReference Include="xunit" Version="2.4.2" />
     <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
   </ItemGroup>

+ 0 - 34
Jint.Tests/Runtime/DateTests.cs

@@ -1,5 +1,3 @@
-using NodaTime;
-
 namespace Jint.Tests.Runtime;
 
 public class DateTests
@@ -100,38 +98,6 @@ public class DateTests
         Assert.Equal(expected, _engine.Evaluate($"new Date('{input}') * 1").AsNumber());
     }
 
-    [Theory]
-    [InlineData("401, 0, 1, 0, 0, 0, 0", -49512821989000)]
-    [InlineData("1900, 0, 1, 0, 0, 0, 0", -2208994789000)]
-    [InlineData("1920, 0, 1, 0, 0, 0, 0", -1577929189000)]
-    [InlineData("1969, 0, 1, 0, 0, 0, 0", -31543200000)]
-    [InlineData("2000, 1, 1, 1, 1, 1, 1", 949359661001)]
-    public void CanProduceValidDatesUsingNodaTimeIntegration(string input, long expected)
-    {
-        var dateTimeZone = DateTimeZoneProviders.Tzdb["Europe/Helsinki"];
-        TimeZoneInfo timeZone;
-        try
-        {
-            timeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Helsinki");
-        }
-        catch (TimeZoneNotFoundException)
-        {
-            timeZone = TimeZoneInfo.FindSystemTimeZoneById("FLE Standard Time");
-        }
-
-        var engine = new Engine(options =>
-        {
-            options.TimeZone = timeZone;
-            options.GetUtcOffset = milliseconds =>
-            {
-                var offset = dateTimeZone.GetUtcOffset(Instant.FromUnixTimeMilliseconds(milliseconds));
-                return offset.ToTimeSpan();
-            };
-        });
-
-        Assert.Equal(expected, engine.Evaluate($"new Date({input}) * 1").AsNumber());
-    }
-
     [Fact]
     public void CanUseMoment()
     {

+ 7 - 95
Jint/Native/Date/DateConstructor.cs

@@ -1,4 +1,3 @@
-using System.Globalization;
 using Jint.Collections;
 using Jint.Native.Function;
 using Jint.Native.Object;
@@ -14,48 +13,8 @@ namespace Jint.Native.Date;
 internal sealed class DateConstructor : Constructor
 {
     internal static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-
-    private static readonly string[] DefaultFormats = {
-        "yyyy-MM-dd",
-        "yyyy-MM",
-        "yyyy"
-    };
-
-    private static readonly string[] SecondaryFormats = {
-        "yyyy-MM-ddTHH:mm:ss.FFF",
-        "yyyy-MM-ddTHH:mm:ss",
-        "yyyy-MM-ddTHH:mm",
-
-        // Formats used in DatePrototype toString methods
-        "ddd MMM dd yyyy HH:mm:ss 'GMT'K",
-        "ddd MMM dd yyyy",
-        "HH:mm:ss 'GMT'K",
-
-        // standard formats
-        "yyyy-M-dTH:m:s.FFFK",
-        "yyyy/M/dTH:m:s.FFFK",
-        "yyyy-M-dTH:m:sK",
-        "yyyy/M/dTH:m:sK",
-        "yyyy-M-dTH:mK",
-        "yyyy/M/dTH:mK",
-        "yyyy-M-d H:m:s.FFFK",
-        "yyyy/M/d H:m:s.FFFK",
-        "yyyy-M-d H:m:sK",
-        "yyyy/M/d H:m:sK",
-        "yyyy-M-d H:mK",
-        "yyyy/M/d H:mK",
-        "yyyy-M-dK",
-        "yyyy/M/dK",
-        "yyyy-MK",
-        "yyyy/MK",
-        "yyyyK",
-        "THH:mm:ss.FFFK",
-        "THH:mm:ssK",
-        "THH:mmK",
-        "THHK"
-    };
-
     private static readonly JsString _functionName = new JsString("Date");
+    private readonly ITimeSystem _timeSystem;
 
     internal DateConstructor(
         Engine engine,
@@ -68,6 +27,7 @@ internal sealed class DateConstructor : Constructor
         PrototypeObject = new DatePrototype(engine, this, objectPrototype);
         _length = new PropertyDescriptor(7, PropertyFlag.Configurable);
         _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
+        _timeSystem = engine.Options.TimeSystem;
     }
 
     internal DatePrototype PrototypeObject { get; }
@@ -101,43 +61,13 @@ internal sealed class DateConstructor : Constructor
     /// </summary>
     private DatePresentation ParseFromString(string date)
     {
-        var negative = date.StartsWith("-");
-        if (negative)
-        {
-            date = date.Substring(1);
-        }
-
-        var startParen = date.IndexOf('(');
-        if (startParen != -1)
+        if (_timeSystem.TryParse(date, out var result))
         {
-            // informative text
-            date = date.Substring(0, startParen);
-        }
-
-        date = date.Trim();
-
-        if (!DateTime.TryParseExact(date, DefaultFormats, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var result))
-        {
-            if (!DateTime.TryParseExact(date, SecondaryFormats, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out result))
-            {
-                if (!DateTime.TryParse(date, Engine.Options.Culture, DateTimeStyles.AdjustToUniversal, out result))
-                {
-                    if (!DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out result))
-                    {
-                        // fall back to trying with MimeKit
-                        if (DateUtils.TryParse(date, out var mimeKitResult))
-                        {
-                            return FromDateTimeOffset(mimeKitResult);
-                        }
-
-                        // unrecognized dates should return NaN (15.9.4.2)
-                        return DatePresentation.NaN;
-                    }
-                }
-            }
+            return result;
         }
 
-        return FromDateTime(result, negative);
+        // unrecognized dates should return NaN
+        return DatePresentation.NaN;
     }
 
     /// <summary>
@@ -262,25 +192,6 @@ internal sealed class DateConstructor : Constructor
             static (engine, _, dateValue) => new JsDate(engine, dateValue), time);
     }
 
-    private static long FromDateTimeOffset(DateTimeOffset dt, bool negative = false)
-    {
-        var dateAsUtc = dt.ToUniversalTime();
-
-        double result;
-        if (negative)
-        {
-            var zero = (Epoch - DateTime.MinValue).TotalMilliseconds;
-            result = zero - TimeSpan.FromTicks(dateAsUtc.Ticks).TotalMilliseconds;
-            result *= -1;
-        }
-        else
-        {
-            result = (dateAsUtc - Epoch).TotalMilliseconds;
-        }
-
-        return (long) System.Math.Floor(result);
-    }
-
     internal DatePresentation FromDateTime(DateTime dt, bool negative = false)
     {
         if (dt == DateTime.MinValue)
@@ -319,4 +230,5 @@ internal sealed class DateConstructor : Constructor
 
         return result;
     }
+
 }

+ 24 - 12
Jint/Native/Date/DatePrototype.cs

@@ -1,4 +1,3 @@
-using System.Globalization;
 using System.Runtime.CompilerServices;
 using Jint.Collections;
 using Jint.Native.Object;
@@ -21,6 +20,7 @@ namespace Jint.Native.Date
         private const double MaxMonth = -MinMonth;
 
         private readonly DateConstructor _constructor;
+        private readonly ITimeSystem _timeSystem;
 
         internal DatePrototype(
             Engine engine,
@@ -30,6 +30,7 @@ namespace Jint.Native.Date
         {
             _prototype = objectPrototype;
             _constructor = constructor;
+            _timeSystem = engine.Options.TimeSystem;
         }
 
         protected override  void Initialize()
@@ -84,7 +85,7 @@ namespace Jint.Native.Date
                 ["setUTCFullYear"] = new PropertyDescriptor(new ClrFunctionInstance(Engine, "setUTCFullYear", SetUTCFullYear, 3, lengthFlags), propertyFlags),
                 ["toUTCString"] = new PropertyDescriptor(new ClrFunctionInstance(Engine, "toUTCString", ToUtcString, 0, lengthFlags), propertyFlags),
                 ["toISOString"] = new PropertyDescriptor(new ClrFunctionInstance(Engine, "toISOString", ToISOString, 0, lengthFlags), propertyFlags),
-                ["toJSON"] = new PropertyDescriptor(new ClrFunctionInstance(Engine, "toJSON", ToJSON, 1, lengthFlags), propertyFlags)
+                ["toJSON"] = new PropertyDescriptor(new ClrFunctionInstance(Engine, "toJSON", ToJson, 1, lengthFlags), propertyFlags)
             };
             SetProperties(properties);
 
@@ -772,10 +773,13 @@ namespace Jint.Native.Date
                 return "Invalid Date";
             }
 
+            var weekday = _dayNames[WeekDay(tv)];
+            var month = _monthNames[MonthFromTime(tv)];
+            var day = DateFromTime(tv).ToString("00");
             var yv = YearFromTime(tv);
-            var universalTime = tv.ToDateTime().ToUniversalTime();
-            var paddedYear = yv.ToString().PadLeft(4, '0');
-            return universalTime.ToString($"ddd, dd MMM {paddedYear} HH:mm:ss 'GMT'", CultureInfo.InvariantCulture);
+            var paddedYear = yv.ToString("0000");
+
+            return $"{weekday}, {day} {month} {paddedYear} {TimeString(tv)}";
         }
 
         /// <summary>
@@ -807,10 +811,18 @@ namespace Jint.Native.Date
             if (ms < 0) { ms += MsPerSecond; }
 
             var (year, month, day) = YearMonthDayFromTime(t);
-            return $"{year:0000}-{month:00}-{day:00}T{h:00}:{m:00}:{s:00}.{ms:000}Z";
+            month++;
+
+            var formatted = $"{year:0000}-{month:00}-{day:00}T{h:00}:{m:00}:{s:00}.{ms:000}Z";
+            if (year > 9999)
+            {
+                formatted = "+" + formatted;
+            }
+
+            return formatted;
         }
 
-        private JsValue ToJSON(JsValue thisObj, JsValue[] arguments)
+        private JsValue ToJson(JsValue thisObj, JsValue[] arguments)
         {
             var o = TypeConverter.ToObject(_realm, thisObj);
             var tv = TypeConverter.ToPrimitive(o, Types.Number);
@@ -1094,7 +1106,7 @@ namespace Jint.Native.Date
 
         private DateTime ToLocalTime(DatePresentation t)
         {
-            var utcOffset = _engine.Options.GetUtcOffset(t.Value).TotalMilliseconds;
+            var utcOffset = _timeSystem.GetUtcOffset(t.Value).TotalMilliseconds;
             return (t + utcOffset).ToDateTime();
         }
 
@@ -1108,13 +1120,13 @@ namespace Jint.Native.Date
                 return DatePresentation.NaN;
             }
 
-            var offset = Engine.Options.GetUtcOffset(t.Value).TotalMilliseconds;
+            var offset = _timeSystem.GetUtcOffset(t.Value).TotalMilliseconds;
             return t + offset;
         }
 
         internal DatePresentation Utc(DatePresentation t)
         {
-            var offset = Engine.Options.GetUtcOffset(t.Value).TotalMilliseconds;
+            var offset = _timeSystem.GetUtcOffset(t.Value).TotalMilliseconds;
             return t - offset;
         }
 
@@ -1376,7 +1388,7 @@ namespace Jint.Native.Date
         /// </summary>
         private string TimeZoneString(DatePresentation tv)
         {
-            var offset = Engine.Options.GetUtcOffset(tv.Value).TotalMilliseconds;
+            var offset = _timeSystem.GetUtcOffset(tv.Value).TotalMilliseconds;
 
             string offsetSign;
             double absOffset;
@@ -1394,7 +1406,7 @@ namespace Jint.Native.Date
             var offsetMin = MinFromTime(absOffset).ToString("00");
             var offsetHour = HourFromTime(absOffset).ToString("00");
 
-            var tzName = " (" + _engine.Options.TimeZone.StandardName + ")";
+            var tzName = " (" + _timeSystem.DefaultTimeZone.StandardName + ")";
 
             return offsetSign + offsetHour + offsetMin + tzName;
         }

+ 6 - 1
Jint/Native/DatePresentation.cs

@@ -79,6 +79,11 @@ internal readonly record struct DatePresentation(long Value, DateFlags Flags)
 
     internal JsNumber ToJsValue()
     {
-        return IsNaN ? JsNumber.DoubleNaN : JsNumber.Create(Value);
+        if (IsNaN || Value is < -8640000000000000 or > 8640000000000000)
+        {
+            return JsNumber.DoubleNaN;
+        }
+
+        return JsNumber.Create(Value);
     }
 }

+ 1 - 1
Jint/Native/JsDate.cs

@@ -57,7 +57,7 @@ public sealed class JsDate : ObjectInstance
             var dateTime = DateConstructor.Epoch.AddMilliseconds(_dateValue.Value);
             if (_engine.Options.Interop.DateTimeKind == DateTimeKind.Local)
             {
-                dateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, _engine.Options.TimeZone);
+                dateTime = TimeZoneInfo.ConvertTimeFromUtc(dateTime, _engine.Options.TimeSystem.DefaultTimeZone);
                 dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Local);
             }
             return dateTime;

+ 11 - 21
Jint/Options.cs

@@ -19,22 +19,9 @@ namespace Jint
 
     public class Options
     {
-        internal List<Action<Engine>> _configurations { get; } = new();
+        private ITimeSystem? _timeSystem;
 
-        public Options()
-        {
-            GetUtcOffset = milliseconds =>
-            {
-                // we have limited capabilities without addon like NodaTime
-                if (milliseconds is < -62135596800000L or > 253402300799999L)
-                {
-                    return this.TimeZone.BaseUtcOffset;
-                }
-
-                var utcOffset = this.TimeZone.GetUtcOffset(DateTimeOffset.FromUnixTimeMilliseconds(milliseconds));
-                return utcOffset;
-            };
-        }
+        internal List<Action<Engine>> _configurations { get; } = new();
 
         /// <summary>
         /// Execution constraints for the engine.
@@ -71,17 +58,20 @@ namespace Jint
         /// </summary>
         public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture;
 
+
         /// <summary>
-        /// The time zone the engine runs on, defaults to local.
+        /// Configures a time system to use. Defaults to DefaultTimeSystem using local time.
         /// </summary>
-        public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local;
+        public ITimeSystem TimeSystem
+        {
+            get => _timeSystem ??= new DefaultTimeSystem(TimeZone, Culture);
+            set => _timeSystem = value;
+        }
 
         /// <summary>
-        /// Retrieves UTC offset for given date presented as milliseconds since the Unix epoch. May be negative (for instants before the epoch).
-        /// Defaults to using <see cref="TimeZoneInfo.GetUtcOffset(System.DateTimeOffset)"/> using the configured time zone.
+        /// The time zone the engine runs on, defaults to local. Same as setting DefaultTimeSystem with the time zone.
         /// </summary>
-        /// <seealso cref="TimeZone"/>
-        public Func<long, TimeSpan> GetUtcOffset { get; set; }
+        public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Local;
 
         /// <summary>
         /// Reference resolver allows customizing behavior for reference resolving. This can be useful in cases where

+ 161 - 0
Jint/Runtime/DefaultTimeSystem.cs

@@ -0,0 +1,161 @@
+using System.Globalization;
+using Jint.Native;
+using Jint.Native.Date;
+
+namespace Jint.Runtime;
+
+public class DefaultTimeSystem : ITimeSystem
+{
+    private static readonly string[] _defaultFormats = { "yyyy-MM-dd", "yyyy-MM", "yyyy" };
+
+    private static readonly string[] _secondaryFormats =
+    {
+        "yyyy-MM-ddTHH:mm:ss.FFF",
+        "yyyy-MM-ddTHH:mm:ss",
+        "yyyy-MM-ddTHH:mm",
+
+        // Formats used in DatePrototype toString methods
+        "ddd MMM dd yyyy HH:mm:ss 'GMT'K",
+        "ddd MMM dd yyyy",
+        "HH:mm:ss 'GMT'K",
+
+        // standard formats
+        "yyyy-M-dTH:m:s.FFFK",
+        "yyyy/M/dTH:m:s.FFFK",
+        "yyyy-M-dTH:m:sK",
+        "yyyy/M/dTH:m:sK",
+        "yyyy-M-dTH:mK",
+        "yyyy/M/dTH:mK",
+        "yyyy-M-d H:m:s.FFFK",
+        "yyyy/M/d H:m:s.FFFK",
+        "yyyy-M-d H:m:sK",
+        "yyyy/M/d H:m:sK",
+        "yyyy-M-d H:mK",
+        "yyyy/M/d H:mK",
+        "yyyy-M-dK",
+        "yyyy/M/dK",
+        "yyyy-MK",
+        "yyyy/MK",
+        "yyyyK",
+        "THH:mm:ss.FFFK",
+        "THH:mm:ssK",
+        "THH:mmK",
+        "THHK"
+    };
+
+    private readonly CultureInfo _parsingCulture;
+
+    public DefaultTimeSystem(TimeZoneInfo timeZoneInfo, CultureInfo parsingCulture)
+    {
+        _parsingCulture = parsingCulture;
+        DefaultTimeZone = timeZoneInfo;
+    }
+
+    public TimeZoneInfo DefaultTimeZone { get; }
+
+    public virtual bool TryParse(string date, out long epochMilliseconds)
+    {
+        epochMilliseconds = long.MinValue;
+
+        // special check for large years that always require + or - in front and have 6 digit year
+        if ((date[0] == '+'|| date[0] == '-') && date.IndexOf('-', 1) == 7)
+        {
+            return TryParseLargeYear(date, out epochMilliseconds);
+        }
+
+        var startParen = date.IndexOf('(');
+        if (startParen != -1)
+        {
+            // informative text
+            date = date.Substring(0, startParen);
+        }
+
+        date = date.Trim();
+
+        if (!DateTime.TryParseExact(date, _defaultFormats, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var result))
+        {
+            if (!DateTime.TryParseExact(date, _secondaryFormats, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out result))
+            {
+                if (!DateTime.TryParse(date, _parsingCulture, DateTimeStyles.AdjustToUniversal, out result))
+                {
+                    if (!DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out result))
+                    {
+                        // fall back to trying with MimeKit
+                        if (DateUtils.TryParse(date, out var mimeKitResult))
+                        {
+                            var dateAsUtc = mimeKitResult.ToUniversalTime();
+                            epochMilliseconds = (long) Math.Floor((dateAsUtc - DateConstructor.Epoch).TotalMilliseconds);
+                            return true;
+                        }
+
+                        return false;
+                    }
+                }
+            }
+        }
+
+        var convertToUtcAfter = result.Kind == DateTimeKind.Unspecified;
+
+        var dateAsUtc1 = result.Kind == DateTimeKind.Local
+            ? result.ToUniversalTime()
+            : DateTime.SpecifyKind(result, DateTimeKind.Utc);
+
+        DatePresentation datePresentation = (long) (dateAsUtc1 - DateConstructor.Epoch).TotalMilliseconds;
+
+        if (convertToUtcAfter)
+        {
+            var offset = GetUtcOffset(datePresentation.Value).TotalMilliseconds;
+            datePresentation -= offset;
+        }
+
+        epochMilliseconds = datePresentation.Value;
+        return true;
+    }
+
+    /// <summary>
+    /// Supports parsing of large (> 10 000) and negative years, should not be needed that often...
+    /// </summary>
+    private static bool TryParseLargeYear(string date, out long epochMilliseconds)
+    {
+        epochMilliseconds = long.MinValue;
+
+        var yearString = date.Substring(0, 7);
+        if (!int.TryParse(yearString, out var year))
+        {
+            return false;
+        }
+
+        if (year == 0 && date[0] == '-')
+        {
+            // cannot be negative zero ever
+            return false;
+        }
+
+        // create replacement string
+        var dateToParse = "2000" + date.Substring(7);
+        if (!DateTime.TryParse(dateToParse, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed))
+        {
+            return false;
+        }
+
+        var dateTime = parsed.ToUniversalTime();
+        var datePresentation = DatePrototype.MakeDate(
+            DatePrototype.MakeDay(year, dateTime.Month - 1, dateTime.Day),
+            DatePrototype.MakeTime(dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond)
+        );
+
+        epochMilliseconds = datePresentation.Value;
+        return true;
+    }
+
+    public virtual TimeSpan GetUtcOffset(long epochMilliseconds)
+    {
+        // we have limited capabilities without addon like NodaTime
+        if (epochMilliseconds is < -62135596800000L or > 253402300799999L)
+        {
+            return this.DefaultTimeZone.BaseUtcOffset;
+        }
+
+        return this.DefaultTimeZone.GetUtcOffset(DateTimeOffset.FromUnixTimeMilliseconds(epochMilliseconds));
+    }
+}

+ 34 - 0
Jint/Runtime/ITimeSystem.cs

@@ -0,0 +1,34 @@
+namespace Jint.Runtime;
+
+/// <summary>
+/// Date related operations that can replaced with implementation that can handle also full IANA data as recommended
+/// by the JS spec. Jint comes with <see cref="DefaultTimeSystem"/> which is based on built-in data which might be incomplete.
+/// </summary>
+/// <remarks>
+/// This interface intentionally uses long instead of DateTime/DateTimeOffset as DateTime/DateTimeOffset cannot handle
+/// neither negative years nor the date range that JS can.
+/// </remarks>
+public interface ITimeSystem
+{
+    /// <summary>
+    /// Return the default time zone system is using. Usually <see cref="TimeZoneInfo.Local"/>, but can be altered via
+    /// engine configuration, see <see cref="Options.TimeZone"/>.
+    /// </summary>
+    TimeZoneInfo DefaultTimeZone { get; }
+
+    /// <summary>
+    /// Tries to parse given time presentation string as JS date presentation based on epoch.
+    /// </summary>
+    /// <param name="date">Date/time to parse.</param>
+    /// <param name="epochMilliseconds">The milliseconds since the UNIX epoch, can be negative for values before 1970.</param>
+    /// <returns>true, if succeeded.</returns>
+    bool TryParse(string date, out long epochMilliseconds);
+
+    /// <summary>
+    /// Retrieves UTC offset for given date presented as milliseconds since the Unix epoch.
+    /// Defaults to using <see cref="TimeZoneInfo.GetUtcOffset(System.DateTimeOffset)"/> using the configured time zone.
+    /// </summary>
+    /// <param name="epochMilliseconds">Date as milliseconds since the Unix epoch, may be negative (for instants before the epoch).</param>
+    /// <seealso cref="TimeZone"/>
+    public TimeSpan GetUtcOffset(long epochMilliseconds);
+}