diff --git a/Jint.Tests.Test262/Test262Harness.settings.json b/Jint.Tests.Test262/Test262Harness.settings.json
index e97144f5d0..06a949edd3 100644
--- a/Jint.Tests.Test262/Test262Harness.settings.json
+++ b/Jint.Tests.Test262/Test262Harness.settings.json
@@ -1,10 +1,12 @@
{
- "SuiteGitSha": "f2d59ea6e4b2f6f87de9f0d18a41d73782b6a9bc",
+ "SuiteGitSha": "1a51ced90ea552ffd90450d234ebb426f134fa93",
//"SuiteDirectory": "//mnt/c/work/test262",
"TargetPath": "./Generated",
"Namespace": "Jint.Tests.Test262",
"Parallel": true,
"ExcludedFeatures": [
+ "await-dictionary",
+ "import-bytes",
"import-defer",
"regexp-lookbehind",
"regexp-modifiers",
@@ -239,6 +241,36 @@
// Japanese calendar era boundary resolution
"intl402/Temporal/PlainYearMonth/from/reference-day-japanese.js",
+ "intl402/Temporal/PlainDate/from/japanese-pre-meiji.js",
+
+ // .NET ar-EG locale defaults to latn numbering system instead of arab (Arabic-Indic digits)
+ "intl402/DateTimeFormat/prototype/format/temporal-objects-no-time-clip-non-latin-numerals.js",
+
+ // Leap month numerical months - Chinese/Dangi/Hebrew lunisolar calendar limitations
+ "intl402/Temporal/PlainDate/prototype/add/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/PlainDate/prototype/add/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/PlainDate/prototype/add/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/PlainDate/prototype/subtract/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/PlainDate/prototype/subtract/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/PlainDate/prototype/subtract/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/PlainDateTime/prototype/add/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/PlainDateTime/prototype/add/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/PlainDateTime/prototype/add/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/PlainDateTime/prototype/subtract/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/PlainDateTime/prototype/subtract/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/PlainDateTime/prototype/subtract/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/PlainYearMonth/prototype/add/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/PlainYearMonth/prototype/add/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/PlainYearMonth/prototype/add/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/PlainYearMonth/prototype/subtract/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/PlainYearMonth/prototype/subtract/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/PlainYearMonth/prototype/subtract/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/ZonedDateTime/prototype/add/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/ZonedDateTime/prototype/add/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/ZonedDateTime/prototype/add/leap-month-hebrew-numerical-months.js",
+ "intl402/Temporal/ZonedDateTime/prototype/subtract/leap-month-chinese-numerical-months.js",
+ "intl402/Temporal/ZonedDateTime/prototype/subtract/leap-month-dangi-numerical-months.js",
+ "intl402/Temporal/ZonedDateTime/prototype/subtract/leap-month-hebrew-numerical-months.js",
// PlainMonthDay from - multi-calendar (constrain, reference-date, reference-year)
"intl402/Temporal/PlainMonthDay/from/constrain-to-leap-day.js",
diff --git a/Jint/Native/Intl/DateTimeFormatPrototype.cs b/Jint/Native/Intl/DateTimeFormatPrototype.cs
index 4612fd7134..f16f37bf10 100644
--- a/Jint/Native/Intl/DateTimeFormatPrototype.cs
+++ b/Jint/Native/Intl/DateTimeFormatPrototype.cs
@@ -84,9 +84,9 @@ private ClrFunction GetFormat(JsValue thisObject, JsCallArguments arguments)
if (IsTemporalObject(formattable))
{
var temporalDtf = GetTemporalFormatDtf(dateTimeFormat, formattable);
- var dateTime = ConvertTemporalToDateTime(formattable);
+ var dateTime = ConvertTemporalToDateTime(formattable, out var originalYear);
var isPlain = formattable is not JsInstant;
- return temporalDtf.Format(dateTime, isPlain: isPlain);
+ return temporalDtf.Format(dateTime, originalYear, isPlain: isPlain);
}
else
{
@@ -109,9 +109,9 @@ private JsArray FormatToParts(JsValue thisObject, JsCallArguments arguments)
if (IsTemporalObject(formattable))
{
var temporalDtf = GetTemporalFormatDtf(dateTimeFormat, formattable);
- var dateTime = ConvertTemporalToDateTime(formattable);
+ var dateTime = ConvertTemporalToDateTime(formattable, out var originalYear);
var isPlain = formattable is not JsInstant;
- parts = temporalDtf.FormatToParts(dateTime, isPlain: isPlain);
+ parts = temporalDtf.FormatToParts(dateTime, originalYear, isPlain: isPlain);
}
else
{
@@ -186,6 +186,30 @@ private void ValidateTemporalRangeTypes(JsValue x, JsValue y)
}
}
+ ///
+ /// Validates that two Temporal objects with calendars use the same calendar.
+ /// Per spec: formatRange throws RangeError if calendars differ.
+ ///
+ private void ValidateTemporalCalendarsMatch(JsValue x, JsValue y)
+ {
+ var calX = GetTemporalCalendarId(x);
+ var calY = GetTemporalCalendarId(y);
+ if (calX is not null && calY is not null && !string.Equals(calX, calY, StringComparison.Ordinal))
+ {
+ Throw.RangeError(_realm, "Cannot format a range with Temporal objects using different calendars");
+ }
+ }
+
+ private static string? GetTemporalCalendarId(JsValue value) => value switch
+ {
+ JsPlainDate pd => pd.Calendar,
+ JsPlainDateTime pdt => pdt.Calendar,
+ JsPlainYearMonth ym => ym.Calendar,
+ JsPlainMonthDay md => md.Calendar,
+ JsZonedDateTime zdt => zdt.Calendar,
+ _ => null
+ };
+
///
/// Handles a non-temporal formattable value (Date, Number, undefined).
///
@@ -419,29 +443,89 @@ private JsDateTimeFormat CreatePerTypeFormatDtfFiltered(JsDateTimeFormat dtf, Da
///
/// Converts a Temporal object to a .NET DateTime for formatting.
+ /// When the year is outside .NET's 1-9999 range, a representative year is used
+ /// that preserves leap year status and day-of-week, and the original year is returned.
///
- private static DateTime ConvertTemporalToDateTime(JsValue temporal)
+ private static DateTime ConvertTemporalToDateTime(JsValue temporal, out int? originalYear)
{
+ originalYear = null;
+
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,
+ JsPlainDate pd => CreateDateTimeSafe(pd.IsoDate.Year, pd.IsoDate.Month, pd.IsoDate.Day, 12, 0, 0, 0, out originalYear),
+ JsPlainDateTime pdt => CreateDateTimeSafe(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),
+ pdt.IsoDateTime.Time.Millisecond, out originalYear),
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),
+ JsPlainYearMonth ym => CreateDateTimeSafe(ym.IsoDate.Year, ym.IsoDate.Month, ym.IsoDate.Day, 12, 0, 0, 0, out originalYear),
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
};
}
+ ///
+ /// Creates a DateTime, using a representative year if the actual year is outside .NET's 1-9999 range.
+ /// The representative year preserves leap year status and day-of-week alignment (Gregorian 400-year cycle).
+ ///
+ private static DateTime CreateDateTimeSafe(int year, int month, int day, int hour, int minute, int second, int millisecond, out int? originalYear)
+ {
+ originalYear = null;
+ if (year >= 1 && year <= 9999)
+ {
+ return new DateTime(year, month, day, hour, minute, second, millisecond, DateTimeKind.Unspecified);
+ }
+
+ originalYear = year;
+ // Map to representative year in 1-400 range preserving leap year and day-of-week
+ var repYear = ((year - 1) % 400 + 400) % 400 + 1;
+
+ // Ensure leap year match: if the original year is a leap year but repYear isn't (or vice versa),
+ // adjust by searching nearby in the 400-year cycle
+ var needLeap = DateTime.IsLeapYear(IsLeapYearISO(year) ? 4 : 1); // use IsLeapYearISO for negative years
+ if (IsLeapYearISO(year) != DateTime.IsLeapYear(repYear))
+ {
+ // Shift to nearest matching leap year status within valid range
+ // Leap years in a 400-year cycle: every 4 except 100 except 400
+ for (var offset = 1; offset <= 400; offset++)
+ {
+ var candidate = repYear + offset;
+ if (candidate > 400) candidate -= 400;
+ if (DateTime.IsLeapYear(candidate) == IsLeapYearISO(year))
+ {
+ repYear = candidate;
+ break;
+ }
+ }
+ }
+
+ // For non-leap year rep when day is 29 Feb, constrain to 28
+ if (month == 2 && day == 29 && !DateTime.IsLeapYear(repYear))
+ {
+ day = 28;
+ }
+
+ return new DateTime(repYear, month, day, hour, minute, second, millisecond, DateTimeKind.Unspecified);
+ }
+
+ ///
+ /// ISO 8601 leap year calculation (works for negative years).
+ ///
+ private static bool IsLeapYearISO(int year)
+ {
+ // For negative years, convert to proleptic Gregorian
+ var y = year > 0 ? year : 1 - year; // year 0 = 1 BC, -1 = 2 BC, etc.
+ return (y % 4 == 0) && (y % 100 != 0 || y % 400 == 0);
+ }
+
///
/// Handles a formattable value for formatRange/formatRangeToParts.
///
- private DateTime HandleDateTimeTemporalOrOtherForRange(JsDateTimeFormat dateTimeFormat, JsValue formattable)
+ private DateTime HandleDateTimeTemporalOrOtherForRange(JsDateTimeFormat dateTimeFormat, JsValue formattable, out int? originalYear)
{
+ originalYear = null;
+
if (formattable is JsZonedDateTime)
{
Throw.TypeError(_realm, "Temporal.ZonedDateTime is not supported in DateTimeFormat.formatRange().");
@@ -451,12 +535,12 @@ private DateTime HandleDateTimeTemporalOrOtherForRange(JsDateTimeFormat dateTime
if (formattable is JsPlainDate plainDate)
{
ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true);
- return IsoDateToDateTime(plainDate.IsoDate);
+ return IsoDateToDateTime(plainDate.IsoDate, out originalYear);
}
if (formattable is JsPlainDateTime plainDateTime)
{
- return IsoDateTimeToDateTime(plainDateTime.IsoDateTime);
+ return IsoDateTimeToDateTime(plainDateTime.IsoDateTime, out originalYear);
}
if (formattable is JsPlainTime plainTime)
@@ -468,13 +552,13 @@ private DateTime HandleDateTimeTemporalOrOtherForRange(JsDateTimeFormat dateTime
if (formattable is JsPlainYearMonth yearMonth)
{
ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true);
- return IsoDateToDateTime(yearMonth.IsoDate);
+ return IsoDateToDateTime(yearMonth.IsoDate, out originalYear);
}
if (formattable is JsPlainMonthDay monthDay)
{
ValidateTemporalStyleRestrictions(dateTimeFormat, isDateOnly: true);
- return IsoDateToDateTime(monthDay.IsoDate);
+ return IsoDateToDateTime(monthDay.IsoDate, out originalYear);
}
if (formattable is JsInstant instant)
@@ -505,36 +589,25 @@ private void ValidateTemporalStyleRestrictions(JsDateTimeFormat dateTimeFormat,
}
///
- /// Converts an IsoDate to DateTime (time set to midnight).
+ /// Converts an IsoDate to DateTime (time set to noon for formatting).
+ /// Uses representative year for out-of-range years.
///
- private static DateTime IsoDateToDateTime(IsoDate isoDate)
+ private static DateTime IsoDateToDateTime(IsoDate isoDate, out int? originalYear)
{
- // 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);
+ return CreateDateTimeSafe(isoDate.Year, isoDate.Month, isoDate.Day, 0, 0, 0, 0, out originalYear);
}
///
/// Converts an IsoDateTime to DateTime.
+ /// Uses representative year for out-of-range years.
///
- private static DateTime IsoDateTimeToDateTime(IsoDateTime isoDateTime)
+ private static DateTime IsoDateTimeToDateTime(IsoDateTime isoDateTime, out int? originalYear)
{
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,
+ return CreateDateTimeSafe(date.Year, date.Month, date.Day,
time.Hour, time.Minute, time.Second,
- time.Millisecond, DateTimeKind.Unspecified);
+ time.Millisecond, out originalYear);
}
///
@@ -843,8 +916,11 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments)
// Check SameTemporalType
ValidateTemporalRangeTypes(x, y);
- var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x);
- var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y);
+ // Check calendars match for Temporal objects
+ ValidateTemporalCalendarsMatch(x, y);
+
+ var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x, out var startOrigYear);
+ var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y, out var endOrigYear);
// For Temporal objects, use per-type DTF and isPlain flag
var isTemporalInput = IsTemporalObject(x);
@@ -852,8 +928,8 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments)
var effectiveDtf = isTemporalInput ? GetTemporalFormatDtf(dateTimeFormat, x) : dateTimeFormat;
// Format both dates
- var startFormatted = effectiveDtf.Format(start, isPlain: isPlain);
- var endFormatted = effectiveDtf.Format(end, isPlain: isPlain);
+ var startFormatted = effectiveDtf.Format(start, startOrigYear, isPlain: isPlain);
+ var endFormatted = effectiveDtf.Format(end, endOrigYear, isPlain: isPlain);
// If the dates are the same when formatted, return just one
if (string.Equals(startFormatted, endFormatted, StringComparison.Ordinal))
@@ -862,8 +938,8 @@ private JsValue FormatRange(JsValue thisObject, JsCallArguments arguments)
}
// Get parts to determine shared prefix/suffix for collapsing
- var startParts = effectiveDtf.FormatToParts(start, isPlain: isPlain);
- var endParts = effectiveDtf.FormatToParts(end, isPlain: isPlain);
+ var startParts = effectiveDtf.FormatToParts(start, startOrigYear, isPlain: isPlain);
+ var endParts = effectiveDtf.FormatToParts(end, endOrigYear, isPlain: isPlain);
var sharedPrefixEnd = FindNaturalBoundaryPrefix(startParts, endParts);
var sharedSuffixLen = FindNaturalBoundarySuffix(startParts, endParts, sharedPrefixEnd);
@@ -930,8 +1006,11 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments
// Check SameTemporalType
ValidateTemporalRangeTypes(x, y);
- var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x);
- var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y);
+ // Check calendars match for Temporal objects
+ ValidateTemporalCalendarsMatch(x, y);
+
+ var start = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, x, out var startOrigYear);
+ var end = HandleDateTimeTemporalOrOtherForRange(dateTimeFormat, y, out var endOrigYear);
// For Temporal objects, use per-type DTF and isPlain flag
var isTemporalInput = IsTemporalObject(x);
@@ -939,12 +1018,12 @@ private JsArray FormatRangeToParts(JsValue thisObject, JsCallArguments arguments
var effectiveDtf = isTemporalInput ? GetTemporalFormatDtf(dateTimeFormat, x) : dateTimeFormat;
// Get parts for both dates
- var startParts = effectiveDtf.FormatToParts(start, isPlain: isPlain);
- var endParts = effectiveDtf.FormatToParts(end, isPlain: isPlain);
+ var startParts = effectiveDtf.FormatToParts(start, startOrigYear, isPlain: isPlain);
+ var endParts = effectiveDtf.FormatToParts(end, endOrigYear, isPlain: isPlain);
// Check if dates are practically equal (same formatted output)
- var startFormatted = effectiveDtf.Format(start, isPlain: isPlain);
- var endFormatted = effectiveDtf.Format(end, isPlain: isPlain);
+ var startFormatted = effectiveDtf.Format(start, startOrigYear, isPlain: isPlain);
+ var endFormatted = effectiveDtf.Format(end, endOrigYear, isPlain: isPlain);
var result = new JsArray(Engine);
uint index = 0;
diff --git a/Jint/Native/Intl/JsDateTimeFormat.cs b/Jint/Native/Intl/JsDateTimeFormat.cs
index ff5fae224c..24481fbf97 100644
--- a/Jint/Native/Intl/JsDateTimeFormat.cs
+++ b/Jint/Native/Intl/JsDateTimeFormat.cs
@@ -637,7 +637,15 @@ private void AddYearPart(DateTime dateTime, List result, ref bool
// For proleptic Gregorian calendar with era, convert negative years to positive BC years
// Year 0 in astronomical notation = 1 BC, year -1 = 2 BC, etc.
- var displayYear = Era != null && effectiveYear <= 0 ? 1 - effectiveYear : System.Math.Abs(effectiveYear);
+ int displayYear;
+ if (Era != null && effectiveYear <= 0)
+ {
+ displayYear = 1 - effectiveYear;
+ }
+ else
+ {
+ displayYear = effectiveYear; // Keep sign for iso8601/gregorian without era
+ }
var yearValue = Year switch
{
@@ -845,12 +853,35 @@ private string FormatWithComponents(DateTime dateTime, int? originalYear = null)
});
break;
case 'y' when Year != null:
- parts.Add(Year switch
+ if (originalYear.HasValue)
{
- "numeric" => "yyyy",
- "2-digit" => "yy",
- _ => "yyyy"
- });
+ // Use original year as escaped literal to avoid .NET formatting the representative year
+ var yearVal = originalYear.Value;
+ int displayYear;
+ if (Era != null && yearVal <= 0)
+ {
+ displayYear = 1 - yearVal; // Convert to era-relative positive year
+ }
+ else
+ {
+ displayYear = yearVal; // Keep sign for iso8601/gregorian without era
+ }
+ var yearStr = Year switch
+ {
+ "2-digit" => (System.Math.Abs(displayYear) % 100).ToString("00", CultureInfo),
+ _ => displayYear.ToString(CultureInfo)
+ };
+ parts.Add("'" + yearStr + "'");
+ }
+ else
+ {
+ parts.Add(Year switch
+ {
+ "numeric" => "yyyy",
+ "2-digit" => "yy",
+ _ => "yyyy"
+ });
+ }
break;
}
}
diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs
index 3e3d18138f..da331ba054 100644
--- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs
+++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs
@@ -732,8 +732,11 @@ private static bool IsValidOffsetFormat(string offset)
return false;
if (offset.Length == 8)
return true; // HH:MM:SS
- // Fractional seconds
- return offset.Length > 8 && (offset[8] == '.' || offset[8] == ',');
+ // Fractional seconds: must have separator and 1-9 fraction digits
+ if (offset[8] != '.' && offset[8] != ',')
+ return false;
+ var fractionDigits = offset.Length - 9;
+ return fractionDigits >= 1 && fractionDigits <= 9 && AllDigits(offset.AsSpan(9));
}
else
{
@@ -746,10 +749,23 @@ private static bool IsValidOffsetFormat(string offset)
return false;
if (offset.Length == 6)
return true; // HHMMSS
- // Fractional seconds
- return offset.Length > 6 && (offset[6] == '.' || offset[6] == ',');
+ // Fractional seconds: must have separator and 1-9 fraction digits
+ if (offset[6] != '.' && offset[6] != ',')
+ return false;
+ var fractionDigits = offset.Length - 7;
+ return fractionDigits >= 1 && fractionDigits <= 9 && AllDigits(offset.AsSpan(7));
}
}
+ private static bool AllDigits(ReadOnlySpan span)
+ {
+ foreach (var c in span)
+ {
+ if (!char.IsDigit(c))
+ return false;
+ }
+ return true;
+ }
+
private readonly record struct ParsedDateTimeResult(IsoDateTime? DateTime, string? Error);
}
diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs
index 228e9badba..7e185bf069 100644
--- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs
+++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs
@@ -331,7 +331,8 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue
// 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"
- var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow);
+ // For iso8601 calendar, the year is only used for overflow, not range-checked
+ var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow, skipRangeCheck: true);
if (date is null)
{
Throw.RangeError(_realm, "Invalid month-day");
diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs
index 4b130e177b..ba4130b326 100644
--- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs
+++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs
@@ -191,7 +191,10 @@ private JsPlainMonthDay With(JsValue thisObject, JsCallArguments arguments)
}
// Validate using the provided year (important for leap day validation)
- var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow);
+ // For PlainMonthDay with iso8601 calendar, the year is only used for overflow,
+ // not range-checked (per spec CalendarMonthDayToISOReferenceDate)
+ var skipRangeCheck = string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal);
+ var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow, skipRangeCheck);
if (date is null)
{
Throw.RangeError(_realm, "Invalid month-day");
diff --git a/Jint/Native/Temporal/PlainTime/PlainTimeConstructor.cs b/Jint/Native/Temporal/PlainTime/PlainTimeConstructor.cs
index e4713cc412..5b8defcdfd 100644
--- a/Jint/Native/Temporal/PlainTime/PlainTimeConstructor.cs
+++ b/Jint/Native/Temporal/PlainTime/PlainTimeConstructor.cs
@@ -430,70 +430,12 @@ private static ParsedTimeResult ParseTimeString(string input)
if (offsetPart.Length < 2)
continue; // Offset must have at least HH (2 digits)
- // Quick validation: offset should be digits and optional colons only
- var validOffsetChars = true;
- var hasColonInOffset = false;
- var colonPositions = new List();
- for (var i = 0; i < offsetPart.Length; i++)
- {
- var ch = offsetPart[i];
- if (!char.IsDigit(ch) && ch != ':')
- {
- validOffsetChars = false;
- break;
- }
-
- if (ch == ':')
- {
- hasColonInOffset = true;
- colonPositions.Add(i);
- }
- }
-
- if (!validOffsetChars)
- continue;
-
- // Offset must be one of these lengths: 2 (HH), 4 (HHMM), 5 (HH:MM), 6 (HHMMSS), 8 (HH:MM:SS)
- // Note: 7 characters is NOT a valid offset format
- if (offsetPart.Length != 2 && offsetPart.Length != 4 && offsetPart.Length != 5 &&
- offsetPart.Length != 6 && offsetPart.Length != 8)
+ // Use ParseOffsetString for validation (consolidates offset validation logic)
+ var signChar = timeString[splitPos];
+ var fullOffset = signChar + offsetPart;
+ if (TemporalHelpers.ParseOffsetString(fullOffset) is null)
continue;
- // Check separator consistency in offset
- // Valid patterns: HH, HHMM, HH:MM, HHMMSS, HH:MM:SS
- // Invalid: HH:MMSS, HHMM:SS (mixed separators)
- if (hasColonInOffset)
- {
- // With colons, must be HH:MM or HH:MM:SS format
- // First colon at position 2, second colon (if any) at position 5
- if (colonPositions.Count > 0 && colonPositions[0] != 2)
- continue; // First colon must be after HH
- if (colonPositions.Count > 1 && colonPositions[1] != 5)
- continue; // Second colon must be after MM
- if (colonPositions.Count > 2)
- continue; // At most 2 colons
- }
-
- // Validate offset hour (first 2 digits must be 00-23)
- if (!int.TryParse(offsetPart.AsSpan(0, 2), NumberStyles.None, CultureInfo.InvariantCulture, out var offsetHour) ||
- offsetHour > 23)
- continue;
-
- // Validate offset minute if present (for HH:MM format)
- if (offsetPart.Length >= 5 && offsetPart[2] == ':')
- {
- if (!int.TryParse(offsetPart.AsSpan(3, 2), NumberStyles.None, CultureInfo.InvariantCulture, out var offsetMinute) ||
- offsetMinute > 59)
- continue;
- }
- // Validate offset minute for HHMM format
- else if (offsetPart.Length >= 4 && char.IsDigit(offsetPart[2]) && char.IsDigit(offsetPart[3]))
- {
- if (!int.TryParse(offsetPart.AsSpan(2, 2), NumberStyles.None, CultureInfo.InvariantCulture, out var offsetMinute) ||
- offsetMinute > 59)
- continue;
- }
-
// Try parsing just the time part
time = TemporalHelpers.ParseIsoTime(timePart);
if (time is not null)
@@ -607,15 +549,23 @@ private static ParsedTimeResult ParseTimeFromDateTimeString(string input)
var timeZoneCount = 0;
while (pos < remainder.Length)
{
- // Skip offset
+ // Validate and skip offset
if (remainder[pos] == '+' || remainder[pos] == '-')
{
+ var offsetStart = pos;
pos++;
while (pos < remainder.Length && remainder[pos] != '[')
{
pos++;
}
+ // Validate offset format (including fraction digit count)
+ var offsetStr = remainder.Substring(offsetStart, pos - offsetStart);
+ if (TemporalHelpers.ParseOffsetString(offsetStr) is null)
+ {
+ return new ParsedTimeResult(null, "Invalid UTC offset format");
+ }
+
continue;
}
diff --git a/Jint/Native/Temporal/TemporalHelpers.cs b/Jint/Native/Temporal/TemporalHelpers.cs
index 455ad9e74f..0302077472 100644
--- a/Jint/Native/Temporal/TemporalHelpers.cs
+++ b/Jint/Native/Temporal/TemporalHelpers.cs
@@ -243,18 +243,42 @@ public static IsoDateTime EpochNanosecondsToIsoDateTime(BigInteger epochNs)
return null;
}
- // Check for optional seconds +HHMMSS (must be exactly 7 characters total)
+ // Check for optional seconds +HHMMSS[.fffffffff]
if (input.Length > 5)
{
- if (input.Length != 7)
+ if (input.Length < 7)
{
- return null; // Invalid format - must be exactly +HHMMSS
+ return null; // Invalid format - need at least +HHMMSS
}
if (!int.TryParse(input.AsSpan(5, 2), NumberStyles.Integer, CultureInfo.InvariantCulture, out seconds))
{
return null;
}
+
+ // Check for fractional seconds after +HHMMSS
+ if (input.Length > 7)
+ {
+ if (input[7] != '.' && input[7] != ',')
+ {
+ return null;
+ }
+
+ var fractionStart = 8;
+ var fractionLength = input.Length - fractionStart;
+
+ if (fractionLength < 1 || fractionLength > 9)
+ {
+ return null;
+ }
+
+ if (!int.TryParse(input.AsSpan(fractionStart, fractionLength), NumberStyles.Integer, CultureInfo.InvariantCulture, out var fractionValue))
+ {
+ return null;
+ }
+
+ nanoseconds = fractionValue * (long) System.Math.Pow(10, 9 - fractionLength);
+ }
}
}
else
@@ -760,7 +784,7 @@ public static bool IsLowercaseAnnotationKey(string key)
// Fractions are only allowed after seconds, not after hours or minutes
// Per spec TimeSpec grammar: Hour | Hour TimeSeparator MinuteSecond | ...
private static readonly Regex TimeWithOffsetPattern = new(
- @"^(\d{2})(?:(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?)|(?:(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?))??(?:([Zz])|([+-])(\d{2}):?(\d{2})(?::?(\d{2})(?:[.,](\d{1,9}))?)?)?$",
+ @"^(\d{2})(?:(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?)|(?:(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?))??(?:([Zz])|([+-])(\d{2})(?::?(\d{2})(?::?(\d{2})(?:[.,](\d{1,9}))?)?)?)?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant,
RegexTimeout);
#pragma warning restore MA0023
@@ -1791,7 +1815,7 @@ internal static bool CalendarUsesEras(string calendar)
case "gregory":
return isoYear >= 1 ? "ce" : "bce";
case "roc":
- return isoYear >= 1912 ? "minguo" : "before-roc";
+ return isoYear >= 1912 ? "roc" : "broc";
case "buddhist":
return "be";
case "japanese":
@@ -1913,9 +1937,9 @@ private static int ComputeYearFromEra(Realm realm, string calendar, string era,
return eraYear; // simplified
break;
case "roc":
- if (era is "minguo" or "roc")
+ if (era is "roc" or "minguo")
return eraYear;
- if (era is "before-roc" or "before-roc-inverse")
+ if (era is "broc" or "before-roc" or "before-roc-inverse")
return 1 - eraYear;
break;
case "buddhist":
@@ -2261,7 +2285,7 @@ private static bool IsoDateWithinLimits(IsoDate date)
///
/// Validates and regulates ISO date fields.
///
- public static IsoDate? RegulateIsoDate(int year, int month, int day, string overflow)
+ public static IsoDate? RegulateIsoDate(int year, int month, int day, string overflow, bool skipRangeCheck = false)
{
if (string.Equals(overflow, "constrain", StringComparison.Ordinal))
{
@@ -2269,15 +2293,17 @@ private static bool IsoDateWithinLimits(IsoDate date)
var daysInMonth = IsoDate.IsoDateInMonth(year, month);
day = Clamp(day, 1, daysInMonth);
var date = new IsoDate(year, month, day);
- // Even with constrain, check Temporal limits
- return IsValidIsoDateTime(year, month, day) ? date : null;
+ // Even with constrain, check Temporal limits (unless skipped for PlainMonthDay)
+ return skipRangeCheck || IsValidIsoDateTime(year, month, day) ? date : null;
}
if (string.Equals(overflow, "reject", StringComparison.Ordinal))
{
var date = new IsoDate(year, month, day);
- // Check both basic validity and Temporal limits
- return date.IsValid() && IsValidIsoDateTime(year, month, day) ? date : null;
+ // Check basic validity; also check Temporal limits unless skipped for PlainMonthDay
+ if (!date.IsValid())
+ return null;
+ return skipRangeCheck || IsValidIsoDateTime(year, month, day) ? date : null;
}
return null;
@@ -5010,49 +5036,34 @@ private static BigInteger DisambiguatePossibleEpochNanoseconds(
Throw.RangeError(realm, "Ambiguous time with disambiguation=reject");
}
- // Step 8: No possibilities (DST spring forward - gap)
+ // Step 7: No possibilities (gap) — reject throws
if (string.Equals(disambiguation, "reject", StringComparison.Ordinal))
{
Throw.RangeError(realm, "Time does not exist in timezone (DST gap) with disambiguation=reject");
}
- // Steps 9-19: Find the epoch time during the gap
- // For "earlier": use the time before the gap
- // For "compatible" or "later": use the time after the gap
+ // Step 8: Let epochNanoseconds be GetUTCEpochNanoseconds(isoDateTime)
+ var epochNanoseconds = GetUTCEpochNanoseconds(isoDateTime);
- // Simplified implementation: Try adjusting by 1 hour in each direction
- // A full implementation would use binary search to find exact gap boundaries
+ // Step 9-10: dayBefore and dayAfter
+ var dayBefore = epochNanoseconds - NanosecondsPerDay;
+ var dayAfter = epochNanoseconds + NanosecondsPerDay;
- // Try 1 hour earlier
- var oneHourNs = NanosecondsPerHour;
- var earlierEpochGuess = GetUTCEpochNanoseconds(isoDateTime) - oneHourNs;
- var earlierDateTimeGuess = EpochNanosecondsToIsoDateTime(earlierEpochGuess);
- var earlierPossible = GetPossibleEpochNanoseconds(realm, timeZoneProvider, timeZone, earlierDateTimeGuess);
+ // Step 11-12: Get offsets at ±1 day
+ var offsetBefore = (BigInteger) timeZoneProvider.GetOffsetNanosecondsFor(timeZone, dayBefore);
+ var offsetAfter = (BigInteger) timeZoneProvider.GetOffsetNanosecondsFor(timeZone, dayAfter);
- if (string.Equals(disambiguation, "earlier", StringComparison.Ordinal) && earlierPossible.Length > 0)
+ // For gaps: "earlier" resolves to the wall-clock time BEFORE the gap,
+ // "compatible"/"later" resolves to the wall-clock time AFTER the gap.
+ // epochNs - offsetAfter gives the instant before the gap,
+ // epochNs - offsetBefore gives the instant after the gap.
+ if (string.Equals(disambiguation, "earlier", StringComparison.Ordinal))
{
- return earlierPossible[0];
+ return epochNanoseconds - offsetAfter;
}
- // Try 1 hour later
- var laterEpochGuess = GetUTCEpochNanoseconds(isoDateTime) + oneHourNs;
- var laterDateTimeGuess = EpochNanosecondsToIsoDateTime(laterEpochGuess);
- var laterPossible = GetPossibleEpochNanoseconds(realm, timeZoneProvider, timeZone, laterDateTimeGuess);
-
- if (laterPossible.Length > 0)
- {
- return laterPossible[laterPossible.Length - 1];
- }
-
- // Fallback for "earlier" if forward search worked
- if (earlierPossible.Length > 0)
- {
- return earlierPossible[0];
- }
-
- // Should not reach here - throw error
- Throw.RangeError(realm, "Could not determine epoch time for ambiguous datetime");
- return BigInteger.Zero; // unreachable
+ // "compatible" or "later" → after the gap
+ return epochNanoseconds - offsetBefore;
}
///
@@ -5094,44 +5105,23 @@ public static BigInteger GetStartOfDay(
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.
+ // Step 4: Assert possibleEpochNs is empty (midnight is in a gap)
+ // Step 5: Let startBefore be GetUTCEpochNanoseconds(midnight) - NsPerDay
var utcMidnightNs = (BigInteger) IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day) * NanosecondsPerDay;
+ var startBefore = utcMidnightNs - 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);
+ // Step 6: Let nextTransition be GetNamedTimeZoneNextTransition(timeZone, startBefore)
+ var nextTransition = provider.GetNextTransition(timeZone, startBefore);
- // Find the transition whose new local time lands on our target date
- while (transition.HasValue && transition.Value < utcMidnightNs + NanosecondsPerDay)
+ // Step 7: Assert nextTransition is not null
+ if (!nextTransition.HasValue)
{
- // 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);
+ Throw.RangeError(realm, "Could not determine start of day");
+ return BigInteger.Zero;
}
- // Fallback: should not reach here for valid timezones
- Throw.RangeError(realm, "Could not determine start of day");
- return BigInteger.Zero;
+ // Step 8: Return nextTransition
+ return nextTransition.Value;
}
///
@@ -5267,8 +5257,8 @@ private static DurationRecord DifferenceZonedDateTime(
// Step 10: If date and time signs disagree, adjust
if (dateSign != 0 && timeSign != 0 && dateSign == -timeSign)
{
- // Step 10a: Adjust date duration
- dateDifference = AdjustDateDurationRecord(dateDifference, -timeSign);
+ // Step 10a: Adjust date duration - reduce magnitude by going in timeSign direction
+ dateDifference = AdjustDateDurationRecord(dateDifference, timeSign);
// Step 10b: Recompute intermediate
intermediateDate = CalendarDateAdd(realm, calendar, startDateTime.Date, dateDifference, "constrain");
diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs
index cbc126d1e3..25872fc2a7 100644
--- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs
+++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs
@@ -418,6 +418,7 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments)
// NOW do remaining algorithmic validation(after all fields and options are read)
// Parse and validate offset
+ // Per spec: if offset not provided in the property bag, use the existing ZonedDateTime's offset
long? offsetNs = null;
if (offsetStr is not null)
{
@@ -427,6 +428,10 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments)
Throw.RangeError(_realm, "Invalid offset string");
}
}
+ else
+ {
+ offsetNs = zdt.OffsetNanoseconds;
+ }
// Parse and validate monthCode
int? parsedMonthCode = null;
@@ -1170,69 +1175,7 @@ private static IsoDate AddDays(IsoDate date, int days)
private BigInteger AddDurationToZonedDateTime(JsZonedDateTime zdt, DurationRecord duration, string overflow)
{
var provider = _engine.Options.Temporal.TimeZoneProvider;
-
- // If only time units, add directly to epoch nanoseconds
- if (duration.Years == 0 && duration.Months == 0 && duration.Weeks == 0 && duration.Days == 0)
- {
- var timeNs = TemporalHelpers.TotalDurationNanoseconds(duration);
- return zdt.EpochNanoseconds + timeNs;
- }
-
- // Otherwise, we need to work with calendar dates
- // Per spec: CalendarDateAdd is called with overflow parameter
- var dt = zdt.GetIsoDateTime();
-
- // Add years and months (CalendarDateAdd step 1-2)
- var year = dt.Date.Year + (int) duration.Years;
- var month = dt.Date.Month + (int) duration.Months;
-
- // Normalize month
- while (month > 12)
- {
- month -= 12;
- year++;
- }
-
- while (month < 1)
- {
- month += 12;
- year--;
- }
-
- // RegulateISODate per spec (CalendarDateAdd step 3)
- // This will throw RangeError if overflow is "reject" and date is invalid
- var regulated = TemporalHelpers.RegulateIsoDate(year, month, dt.Date.Day, overflow);
- if (regulated is null)
- {
- Throw.RangeError(_realm, "Invalid date after adding duration");
- }
-
- // Add weeks and days (CalendarDateAdd step 4-5)
- var totalDays = duration.Weeks * 7 + duration.Days;
- var newDate = totalDays != 0 ? AddDays(regulated.Value, (int) totalDays) : regulated.Value;
-
- // Add time components using BigInteger to avoid overflow
- BigInteger totalNs = dt.Time.TotalNanoseconds() + TemporalHelpers.TimeDurationFromComponents(duration);
-
- // Handle overflow using floor division
- var dayOverflow = TemporalHelpers.FloorDivide(totalNs, TemporalHelpers.NanosecondsPerDay);
- totalNs -= dayOverflow * TemporalHelpers.NanosecondsPerDay;
-
- if (dayOverflow != 0)
- {
- // Validate day overflow is within representable range
- if (dayOverflow > 200_000_000 || dayOverflow < -200_000_000)
- {
- Throw.RangeError(_realm, "Date is outside the valid range");
- }
-
- newDate = AddDays(newDate, (int) dayOverflow);
- }
-
- var newTime = IsoTime.FromNanoseconds((long) totalNs);
- var newDateTime = new IsoDateTime(newDate, newTime);
-
- return GetInstantFor(provider, zdt.TimeZone, newDateTime, "compatible");
+ return TemporalHelpers.AddZonedDateTime(_realm, provider, zdt.EpochNanoseconds, zdt.TimeZone, zdt.Calendar, duration, overflow);
}
///