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); } ///