diff --git a/Jint.Tests.Test262/Test262Harness.settings.json b/Jint.Tests.Test262/Test262Harness.settings.json index d8d02a0005..951a2c7069 100644 --- a/Jint.Tests.Test262/Test262Harness.settings.json +++ b/Jint.Tests.Test262/Test262Harness.settings.json @@ -110,105 +110,29 @@ // Requires Spanish unit patterns (ICU4N doesn't include unit data, only core locale data) "intl402/DurationFormat/prototype/format/mixed-non-numeric-styles-es.js", - // NumberFormat - locale-specific formatting and formatRange + // NumberFormat - string arguments need mathematical value precision (not IEEE 754 double) "intl402/NumberFormat/prototype/format/format-significant-digits.js", - "intl402/NumberFormat/prototype/format/numbering-systems.js", - "intl402/NumberFormat/prototype/format/useGrouping-extended-en-IN.js", "intl402/NumberFormat/prototype/format/value-decimal-string.js", - // formatRange requires locale-specific CLDR range patterns and part collapsing + + // Locale fallback chains - zh-CN should match zh-Hans-CN in supportedLocalesOf + "intl402/fallback-locales-are-supported.js", + "intl402/supportedLocalesOf-consistent-with-resolvedOptions.js", + // NumberFormat - Indian lakh/crore grouping and compact patterns + "intl402/NumberFormat/prototype/format/useGrouping-extended-en-IN.js", + // formatRange - sign collapsing and string precision "intl402/NumberFormat/prototype/formatRange/en-US.js", "intl402/NumberFormat/prototype/formatRange/pt-PT.js", "intl402/NumberFormat/prototype/formatRangeToParts/en-US.js", - // Unit formatting formatToParts not yet implemented + // Unit formatting formatToParts - locale-specific unit patterns "intl402/NumberFormat/prototype/formatToParts/unit-de-DE.js", "intl402/NumberFormat/prototype/formatToParts/unit-ja-JP.js", "intl402/NumberFormat/prototype/formatToParts/unit-ko-KR.js", "intl402/NumberFormat/prototype/formatToParts/unit-zh-TW.js", - // PluralRules - notation requires compact support + // PluralRules - compact notation affects plural category "intl402/PluralRules/prototype/select/notation.js", - // Misc intl402 tests requiring full locale support - "intl402/fallback-locales-are-supported.js", - "intl402/supportedLocalesOf-consistent-with-resolvedOptions.js", - // === INTL402 TEMPORAL EXCLUSIONS === - // Non-ISO calendar arithmetic not yet implemented (Chinese/Dangi lunisolar, Hebrew, Persian, etc.) - // These require CalendarDateAdd, CalendarDateUntil, monthCode, monthsInYear, inLeapYear - // for non-Gregorian-based calendars. - - // Chinese calendar - lunisolar calendar arithmetic - "intl402/Temporal/PlainDate/prototype/inLeapYear/chinese-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/monthCode/chinese-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/monthsInYear/chinese-calendar-leap-days.js", - "intl402/Temporal/PlainDate/prototype/since/chinese-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/subtract/chinese-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/until/chinese-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/with/chinese-calendar-leap-dates.js", - "intl402/Temporal/PlainDateTime/prototype/inLeapYear/chinese-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/monthCode/chinese-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/monthsInYear/chinese-calendar-leap-days.js", - "intl402/Temporal/PlainDateTime/prototype/since/chinese-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/until/chinese-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/with/chinese-calendar-leap-dates.js", - "intl402/Temporal/PlainMonthDay/from/chinese-calendar-dates.js", - "intl402/Temporal/PlainMonthDay/prototype/monthCode/chinese-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/from/reference-day-chinese.js", - "intl402/Temporal/PlainYearMonth/prototype/inLeapYear/chinese-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/monthCode/chinese-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/monthsInYear/chinese-calendar-leap-days.js", - "intl402/Temporal/PlainYearMonth/prototype/since/chinese-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/until/chinese-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/with/chinese-calendar-leap-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/inLeapYear/chinese-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/monthCode/chinese-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/monthsInYear/chinese-calendar-leap-days.js", - "intl402/Temporal/ZonedDateTime/prototype/since/chinese-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/until/chinese-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/with/chinese-calendar-leap-dates.js", - - // Dangi (Korean) calendar - lunisolar calendar arithmetic - "intl402/Temporal/PlainDate/prototype/inLeapYear/dangi-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/monthCode/dangi-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/monthsInYear/dangi-calendar-leap-days.js", - "intl402/Temporal/PlainDate/prototype/since/dangi-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/subtract/dangi-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/until/dangi-calendar-dates.js", - "intl402/Temporal/PlainDate/prototype/with/dangi-calendar-leap-dates.js", - "intl402/Temporal/PlainDateTime/prototype/inLeapYear/dangi-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/monthCode/dangi-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/monthsInYear/dangi-calendar-leap-days.js", - "intl402/Temporal/PlainDateTime/prototype/since/dangi-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/until/dangi-calendar-dates.js", - "intl402/Temporal/PlainDateTime/prototype/with/dangi-calendar-leap-dates.js", - "intl402/Temporal/PlainMonthDay/from/dangi-30-day-leap-months.js", - "intl402/Temporal/PlainMonthDay/from/dangi-calendar-dates.js", - "intl402/Temporal/PlainMonthDay/prototype/monthCode/dangi-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/inLeapYear/dangi-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/monthCode/dangi-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/monthsInYear/dangi-calendar-leap-days.js", - "intl402/Temporal/PlainYearMonth/prototype/since/dangi-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/until/dangi-calendar-dates.js", - "intl402/Temporal/PlainYearMonth/prototype/with/dangi-calendar-leap-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/inLeapYear/dangi-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/monthCode/dangi-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/monthsInYear/dangi-calendar-leap-days.js", - "intl402/Temporal/ZonedDateTime/prototype/since/dangi-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/until/dangi-calendar-dates.js", - "intl402/Temporal/ZonedDateTime/prototype/with/dangi-calendar-leap-dates.js", - - // Non-ISO calendar fields - Hebrew/multi-calendar with() operations - "intl402/Temporal/PlainDateTime/prototype/with/non-iso-calendar-fields.js", - "intl402/Temporal/PlainYearMonth/prototype/with/non-iso-calendar-fields.js", - "intl402/Temporal/ZonedDateTime/prototype/with/non-iso-calendar-fields.js", - - // Leap year since/until across non-ISO calendars (Chinese + Hebrew) - "intl402/Temporal/PlainDate/prototype/since/leap-year-since.js", - "intl402/Temporal/PlainDate/prototype/until/leap-year-until.js", - "intl402/Temporal/PlainDateTime/prototype/since/leap-year-since.js", - "intl402/Temporal/PlainDateTime/prototype/until/leap-year-until.js", - "intl402/Temporal/ZonedDateTime/prototype/since/leap-year-since.js", - "intl402/Temporal/ZonedDateTime/prototype/until/leap-year-until.js", // dateStyle with non-ISO calendar formatting (Islamic month names etc.) "intl402/Temporal/Instant/prototype/toLocaleString/dateStyle.js", @@ -218,56 +142,36 @@ "intl402/Temporal/PlainYearMonth/prototype/toLocaleString/dateStyle.js", "intl402/Temporal/ZonedDateTime/prototype/toLocaleString/dateStyle.js", - // Persian calendar arithmetic - "intl402/Temporal/PlainDate/prototype/toString/basic-persian.js", - - // Calendar era handling - requires Chinese/Hebrew calendar arithmetic - "intl402/Temporal/PlainYearMonth/from/calendar-not-supporting-eras.js", - "intl402/Temporal/ZonedDateTime/from/calendar-not-supporting-eras.js", - - // Hebrew calendar arithmetic (reference-day, add/subtract with leap months) - "intl402/Temporal/PlainYearMonth/from/reference-day-hebrew.js", - "intl402/Temporal/PlainYearMonth/prototype/add/options-undefined.js", - "intl402/Temporal/PlainYearMonth/prototype/subtract/options-undefined.js", + // .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", - // Japanese calendar era boundary resolution + // Japanese era boundary resolution (Heisei/Showa month-day for PlainYearMonth reference day) "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 year until - Hebrew calendar year boundary alignment + "intl402/Temporal/PlainDate/prototype/until/leap-year-until.js", + "intl402/Temporal/PlainDateTime/prototype/until/leap-year-until.js", + "intl402/Temporal/ZonedDateTime/prototype/until/leap-year-until.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) + // PlainMonthDay non-ISO calendar support needs more work + // PlainMonthDay from - reference year selection, constrain-to-leap-day, and lunisolar dates + "intl402/Temporal/PlainMonthDay/from/chinese-calendar-dates.js", "intl402/Temporal/PlainMonthDay/from/constrain-to-leap-day.js", + "intl402/Temporal/PlainMonthDay/from/dangi-30-day-leap-months.js", + "intl402/Temporal/PlainMonthDay/from/dangi-calendar-dates.js", "intl402/Temporal/PlainMonthDay/from/reference-date-noniso-calendar.js", "intl402/Temporal/PlainMonthDay/from/reference-year-1972.js", + // PlainMonthDay monthCode - reference year selection affects month code for Chinese/Dangi + "intl402/Temporal/PlainMonthDay/prototype/monthCode/chinese-calendar-dates.js", + "intl402/Temporal/PlainMonthDay/prototype/monthCode/dangi-calendar-dates.js", + + // PlainYearMonth non-ISO calendar reference day and since + "intl402/Temporal/PlainYearMonth/from/reference-day-chinese.js", + "intl402/Temporal/PlainYearMonth/from/reference-day-hebrew.js", + "intl402/Temporal/PlainYearMonth/prototype/since/chinese-calendar-dates.js", + "intl402/Temporal/PlainYearMonth/prototype/since/dangi-calendar-dates.js", + // === ANNEX B EXCLUSIONS === // Acornima parser limitation: HTML-like comments () in dynamic Function() bodies/params diff --git a/Jint/Native/Temporal/NonIsoCalendars.cs b/Jint/Native/Temporal/NonIsoCalendars.cs new file mode 100644 index 0000000000..4085a66403 --- /dev/null +++ b/Jint/Native/Temporal/NonIsoCalendars.cs @@ -0,0 +1,1089 @@ +using System.Globalization; +using System.Runtime.InteropServices; + +namespace Jint.Native.Temporal; + +/// +/// Provides calendar operations for non-ISO calendars using .NET's built-in Calendar classes. +/// Supports: Chinese, Dangi, Hebrew, Persian. +/// Gregorian-based calendars (iso8601, gregory, japanese, roc, buddhist) are handled +/// directly in TemporalHelpers using ISO arithmetic. +/// +internal static class NonIsoCalendars +{ + // Lazy calendar instances to avoid startup overhead + private static ChineseLunisolarCalendar? _chineseCalendar; + private static KoreanLunisolarCalendar? _koreanCalendar; + private static HebrewCalendar? _hebrewCalendar; + private static PersianCalendar? _persianCalendar; + + private static ChineseLunisolarCalendar ChineseCal => _chineseCalendar ??= new ChineseLunisolarCalendar(); + private static KoreanLunisolarCalendar DangiCal => _koreanCalendar ??= new KoreanLunisolarCalendar(); + private static HebrewCalendar HebrewCal => _hebrewCalendar ??= new HebrewCalendar(); + private static PersianCalendar PersianCal => _persianCalendar ??= new PersianCalendar(); + + /// + /// Result of converting an ISO date to a non-ISO calendar date. + /// + [StructLayout(LayoutKind.Auto)] + internal readonly record struct CalendarDate( + int Year, + int Month, // ordinal month (1-based, includes leap months) + string MonthCode, // M01-M12 or M##L for leap months + int Day, + bool IsLeapMonth, + int MonthsInYear, + int DaysInMonth, + int DaysInYear, + bool InLeapYear); + + /// + /// Returns true if the calendar is a non-ISO calendar supported by this adapter. + /// + internal static bool IsNonIsoCalendar(string calendar) + => calendar is "chinese" or "dangi" or "hebrew" or "persian"; + + /// + /// Converts an ISO date to calendar-specific fields. + /// + internal static CalendarDate IsoToCalendarDate(string calendar, in IsoDate isoDate) + { + try + { + return calendar switch + { + "chinese" => LunisolarToCalendarDate(ChineseCal, isoDate), + "dangi" => LunisolarToCalendarDate(DangiCal, isoDate), + "hebrew" => HebrewToCalendarDate(isoDate), + "persian" => PersianToCalendarDate(isoDate), + _ => throw new NotSupportedException($"Calendar '{calendar}' not supported by NonIsoCalendars") + }; + } + catch (ArgumentOutOfRangeException) + { + // Fall back to ISO-like fields when the ISO date is outside the .NET calendar's range + var monthCode = $"M{isoDate.Month:D2}"; + var daysInMonth = IsoDate.IsoDateInMonth(isoDate.Year, isoDate.Month); + var daysInYear = IsoDate.IsLeapYear(isoDate.Year) ? 366 : 365; + return new CalendarDate(isoDate.Year, isoDate.Month, monthCode, isoDate.Day, false, 12, daysInMonth, daysInYear, IsoDate.IsLeapYear(isoDate.Year)); + } + } + + /// + /// Converts a calendar date to an ISO date. Returns null if the date is invalid with overflow "reject". + /// + internal static IsoDate? CalendarDateToIso(string calendar, int year, string? monthCode, int month, int day, string overflow) + { + var result = calendar switch + { + "chinese" => LunisolarDateToIso(ChineseCal, year, monthCode, month, day, overflow), + "dangi" => LunisolarDateToIso(DangiCal, year, monthCode, month, day, overflow), + "hebrew" => HebrewDateToIso(year, monthCode, month, day, overflow), + "persian" => PersianDateToIso(year, monthCode, month, day, overflow), + _ => throw new NotSupportedException($"Calendar '{calendar}' not supported by NonIsoCalendars") + }; + + // If calendar conversion failed (e.g., year out of .NET calendar range), + // fall back to treating fields as ISO (best effort) + if (result is null && !string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + // Don't fall back for fundamentally invalid monthCodes (display month > 12 or < 1) + if (monthCode is not null) + { + var displayMonth = int.Parse(monthCode.AsSpan(1, 2), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture); + if (displayMonth < 1 || displayMonth > 12) + { + return null; + } + } + + var isoMonth = month > 0 ? month : (monthCode is not null ? int.Parse(monthCode.AsSpan(1, 2), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture) : 1); + return TemporalHelpers.RegulateIsoDate(year, Clamp(isoMonth, 1, 12), day, overflow); + } + + return result; + } + + /// + /// Adds years and months to an ISO date using calendar-specific reckoning. + /// Years are added preserving monthCode semantics (not ordinal month). + /// Months are added as ordinal month steps. + /// + internal static IsoDate CalendarDateAdd(string calendar, in IsoDate isoDate, int years, int months, string overflow) + { + var cal = GetCalendar(calendar); + var calDate = IsoToCalendarDate(calendar, isoDate); + var newYear = calDate.Year + years; + int newOrdinalMonth; + + // When adding years, preserve monthCode (not ordinal month) + if (years != 0 && calDate.IsLeapMonth) + { + // Was on a leap month - check if it exists in the new year + var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, newYear); + if (leapOrdinal > 0 && GetLeapDisplayMonth(calendar, leapOrdinal) == GetLeapDisplayMonth(calendar, GetLeapMonthOrdinal(calendar, cal, calDate.Year))) + { + // Same leap month exists in new year + newOrdinalMonth = leapOrdinal; + } + else + { + // Leap month doesn't exist in new year + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + throw new InvalidOperationException("reject"); + } + + // Constrain to the base month (non-leap version) + newOrdinalMonth = MonthCodeToOrdinal(calendar, cal, newYear, calDate.MonthCode.Substring(0, 3), overflow); + } + } + else if (years != 0) + { + // Not a leap month - resolve the same monthCode in the new year + newOrdinalMonth = MonthCodeToOrdinal(calendar, cal, newYear, calDate.MonthCode, overflow); + } + else + { + newOrdinalMonth = calDate.Month; + } + + // Add months (ordinal stepping) + if (months != 0) + { + newOrdinalMonth += months; + while (newOrdinalMonth > GetMonthsInYear(calendar, cal, newYear)) + { + newOrdinalMonth -= GetMonthsInYear(calendar, cal, newYear); + newYear++; + } + + while (newOrdinalMonth < 1) + { + newYear--; + newOrdinalMonth += GetMonthsInYear(calendar, cal, newYear); + } + } + + // Constrain day + var maxDay = GetDaysInMonthCal(cal, newYear, newOrdinalMonth); + var newDay = calDate.Day; + if (newDay > maxDay) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + throw new InvalidOperationException("reject"); + } + + newDay = maxDay; + } + + // Convert back to ISO + try + { + var dt = cal.ToDateTime(newYear, newOrdinalMonth, newDay, 0, 0, 0, 0); + return new IsoDate(dt.Year, dt.Month, dt.Day); + } + catch (ArgumentOutOfRangeException) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + throw new InvalidOperationException("reject"); + } + + // Fallback: clamp to calendar's valid range + return ClampToCalendarRange(cal, newYear, newOrdinalMonth, newDay); + } + } + + /// + /// Computes the difference between two ISO dates using calendar-specific reckoning. + /// + internal static DurationRecord CalendarDateUntil(string calendar, in IsoDate one, in IsoDate two, string largestUnit) + { + var sign = TemporalHelpers.CompareIsoDates(one, two); + if (sign == 0) + { + return new DurationRecord(0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + // For day and week, use epoch day arithmetic (same as ISO) + if (largestUnit is "day" or "week") + { + var epochOne = TemporalHelpers.IsoDateToDays(one.Year, one.Month, one.Day); + var epochTwo = TemporalHelpers.IsoDateToDays(two.Year, two.Month, two.Day); + var totalDays = (int) (epochTwo - epochOne); + + if (string.Equals(largestUnit, "week", StringComparison.Ordinal)) + { + var w = totalDays / 7; + var d = totalDays - w * 7; + return new DurationRecord(0, 0, w, d, 0, 0, 0, 0, 0, 0); + } + + return new DurationRecord(0, 0, 0, totalDays, 0, 0, 0, 0, 0, 0); + } + + // Calendar-aware year/month difference + sign = -sign; // negate per spec + + var calOne = IsoToCalendarDate(calendar, one); + var calTwo = IsoToCalendarDate(calendar, two); + + int years = 0; + if (string.Equals(largestUnit, "year", StringComparison.Ordinal)) + { + years = calTwo.Year - calOne.Year; + // Check if we overshot + if (years != 0) + { + var check = CalendarDateAdd(calendar, one, years, 0, "constrain"); + if (sign > 0 && TemporalHelpers.CompareIsoDates(check, two) > 0 || + sign < 0 && TemporalHelpers.CompareIsoDates(check, two) < 0) + { + years -= sign; + } + } + } + + int months = 0; + if (largestUnit is "year" or "month") + { + if (string.Equals(largestUnit, "month", StringComparison.Ordinal)) + { + // Estimate total months + var cal = GetCalendar(calendar); + var totalMonths = 0; + if (calTwo.Year == calOne.Year) + { + totalMonths = calTwo.Month - calOne.Month; + } + else + { + // Count months between years + for (var y = calOne.Year; y < calTwo.Year; y++) + { + totalMonths += GetMonthsInYear(calendar, cal, y); + } + + totalMonths += calTwo.Month - calOne.Month; + } + + months = totalMonths; + // Check if we overshot + if (months != 0) + { + var check = CalendarDateAdd(calendar, one, 0, months, "constrain"); + if (sign > 0 && TemporalHelpers.CompareIsoDates(check, two) > 0 || + sign < 0 && TemporalHelpers.CompareIsoDates(check, two) < 0) + { + months -= sign; + } + } + } + else + { + // largestUnit is "year" - count remaining months after years + var intermediate = CalendarDateAdd(calendar, one, years, 0, "constrain"); + var candidateMonths = sign; + while (true) + { + var check = CalendarDateAdd(calendar, one, years, candidateMonths, "constrain"); + if (sign > 0 && TemporalHelpers.CompareIsoDates(check, two) > 0 || + sign < 0 && TemporalHelpers.CompareIsoDates(check, two) < 0) + { + break; + } + + months = candidateMonths; + candidateMonths += sign; + } + } + } + + // Compute remaining days + var afterYearsMonths = CalendarDateAdd(calendar, one, years, months, "constrain"); + var epochIntermediate = TemporalHelpers.IsoDateToDays(afterYearsMonths.Year, afterYearsMonths.Month, afterYearsMonths.Day); + var epochTwoVal = TemporalHelpers.IsoDateToDays(two.Year, two.Month, two.Day); + var remainingDays = (int) (epochTwoVal - epochIntermediate); + + return new DurationRecord(years, months, 0, remainingDays, 0, 0, 0, 0, 0, 0); + } + + /// + /// Returns the era string for a non-ISO calendar. + /// + internal static string? CalendarEra(string calendar, in CalendarDate calDate) + { + return calendar switch + { + "hebrew" => "am", + "persian" => "ap", + "chinese" or "dangi" => null, // no eras + _ => null + }; + } + + /// + /// Returns the eraYear for a non-ISO calendar. + /// + internal static int? CalendarEraYear(string calendar, in CalendarDate calDate) + { + return calendar switch + { + "hebrew" => calDate.Year, + "persian" => calDate.Year, + "chinese" or "dangi" => null, // no eras + _ => null + }; + } + + /// + /// Resolves a monthCode string to an ordinal month number for a given calendar year. + /// Returns the ordinal month, or throws/constrains based on overflow. + /// + internal static int MonthCodeToOrdinal(string calendar, Calendar cal, int year, string monthCode, string overflow) + { + var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; + var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture); + + if (calendar is "persian") + { + // Persian has no leap months + if (isLeap) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + throw new InvalidOperationException("reject"); + } + + // Constrain: use the base month + } + + return Clamp(displayMonth, 1, 12); + } + + var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, year); + + if (isLeap) + { + if (leapOrdinal <= 0) + { + // No leap month this year + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + throw new InvalidOperationException("reject"); + } + + // Constrain: use the base month + return displayMonth < leapOrdinal || leapOrdinal <= 0 ? displayMonth : displayMonth + 1; + } + + var leapDisplay = GetLeapDisplayMonth(calendar, leapOrdinal); + if (displayMonth != leapDisplay) + { + // Wrong leap month + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + throw new InvalidOperationException("reject"); + } + + // Constrain: use the base month + return displayMonth < leapOrdinal ? displayMonth : displayMonth + 1; + } + + return leapOrdinal; + } + + // Non-leap month + if (leapOrdinal > 0 && displayMonth >= leapOrdinal) + { + return displayMonth + 1; + } + + return displayMonth; + } + + /// + /// Validates that month and monthCode are consistent for a non-ISO calendar. + /// If only monthCode is provided, resolves it to an ordinal month. + /// If only month is provided, uses the ordinal directly. + /// If both are provided, validates consistency. + /// + internal static int ResolveMonthAndMonthCode(string calendar, int year, int month, string? monthCode, string overflow) + { + var cal = GetCalendar(calendar); + + if (monthCode is not null && month > 0) + { + // Both provided - validate consistency + var resolvedOrdinal = MonthCodeToOrdinal(calendar, cal, year, monthCode, overflow); + if (resolvedOrdinal != month) + { + throw new InvalidOperationException("reject"); // month and monthCode do not match + } + + return resolvedOrdinal; + } + + if (monthCode is not null) + { + return MonthCodeToOrdinal(calendar, cal, year, monthCode, overflow); + } + + if (month > 0) + { + // Validate ordinal month is in range + var maxMonths = GetMonthsInYear(calendar, cal, year); + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + return Clamp(month, 1, maxMonths); + } + + if (month < 1 || month > maxMonths) + { + throw new InvalidOperationException("reject"); + } + + return month; + } + + throw new InvalidOperationException("reject"); // neither month nor monthCode provided + } + + /// + /// Finds a calendar year where (year, monthCode, day) converts to an ISO date + /// with the given ISO reference year. Used for PlainMonthDay without explicit year. + /// + internal static int FindCalendarReferenceYear(string calendar, int isoReferenceYear, string monthCode, int day) + { + var cal = GetCalendar(calendar); + + // For lunisolar calendars, a calendar year spans parts of two ISO years + // Try multiple calendar years around the ISO reference year + var approxYear = IsoYearToCalendarYear(calendar, cal, isoReferenceYear); + + for (var y = approxYear - 2; y <= approxYear + 2; y++) + { + try + { + var ordinal = MonthCodeToOrdinal(calendar, cal, y, monthCode, "reject"); + var maxDay = GetDaysInMonthCal(cal, y, ordinal); + var clampedDay = System.Math.Min(day, maxDay); + var dt = cal.ToDateTime(y, ordinal, clampedDay, 0, 0, 0, 0); + if (dt.Year == isoReferenceYear) + { + return y; + } + } + catch + { + // This year doesn't have the requested monthCode + } + } + + return approxYear; + } + + #region Private Helpers + + private static int Clamp(int value, int min, int max) + { + return value < min ? min : value > max ? max : value; + } + + /// + /// Determines if a Hebrew year is a leap year using the 19-year Metonic cycle. + /// Works for any Hebrew year, including those outside .NET HebrewCalendar's range. + /// In each 19-year cycle, years 3, 6, 8, 11, 14, 17, 19 are leap years. + /// + private static bool IsHebrewLeapYearAlgorithmic(int year) + { + return (7 * year + 1) % 19 < 7; + } + + private static Calendar GetCalendar(string calendar) + { + return calendar switch + { + "chinese" => ChineseCal, + "dangi" => DangiCal, + "hebrew" => HebrewCal, + "persian" => PersianCal, + _ => throw new NotSupportedException($"Calendar '{calendar}' not supported") + }; + } + + /// + /// Converts an ISO year to an approximate calendar year. + /// + private static int IsoYearToCalendarYear(string calendar, Calendar cal, int isoYear) + { + try + { + // Use July 1 of the ISO year as a reference point + var dt = new DateTime(System.Math.Max(1, System.Math.Min(isoYear, 9999)), 7, 1); + if (dt >= cal.MinSupportedDateTime && dt <= cal.MaxSupportedDateTime) + { + return cal.GetYear(dt); + } + } + catch + { + // Fall through + } + + return isoYear; + } + + /// + /// Gets the ordinal position of the leap month in a given year. + /// Returns 0 if the year has no leap month. + /// + private static int GetLeapMonthOrdinal(string calendar, Calendar cal, int year) + { + if (calendar is "persian") + { + return 0; // Persian never has leap months + } + + if (calendar is "hebrew") + { + try + { + return HebrewCal.IsLeapYear(year) ? 6 : 0; + } + catch + { + // Out of .NET range: use algorithmic 19-year cycle + // Years 3, 6, 8, 11, 14, 17, 19 of each 19-year cycle are leap years + return IsHebrewLeapYearAlgorithmic(year) ? 6 : 0; + } + } + + // Chinese/Dangi: use EastAsianLunisolarCalendar.GetLeapMonth + try + { + return ((EastAsianLunisolarCalendar) cal).GetLeapMonth(year); + } + catch + { + return 0; + } + } + + /// + /// Gets the display month number of the leap month at the given ordinal. + /// For Chinese/Dangi: leapOrdinal 5 → display 4 (M04L). + /// For Hebrew: leapOrdinal 6 → display 5 (M05L). + /// + private static int GetLeapDisplayMonth(string calendar, int leapOrdinal) + { + return leapOrdinal - 1; + } + + /// + /// Gets the number of months in a calendar year. + /// + private static int GetMonthsInYear(string calendar, Calendar cal, int year) + { + if (calendar is "persian") + { + return 12; + } + + try + { + return cal.GetMonthsInYear(year); + } + catch + { + // For Hebrew out-of-range, use algorithmic leap year detection + if (calendar is "hebrew") + { + return IsHebrewLeapYearAlgorithmic(year) ? 13 : 12; + } + + return 12; + } + } + + /// + /// Gets the number of days in a calendar month. + /// + private static int GetDaysInMonthCal(Calendar cal, int year, int month) + { + try + { + return cal.GetDaysInMonth(year, month); + } + catch + { + return 30; // fallback + } + } + + /// + /// Clamps a date to the calendar's supported range. + /// + private static IsoDate ClampToCalendarRange(Calendar cal, int year, int month, int day) + { + var dt = cal.MaxSupportedDateTime; + return new IsoDate(dt.Year, dt.Month, dt.Day); + } + + #endregion + + #region Lunisolar (Chinese/Dangi) Calendar + + private static CalendarDate LunisolarToCalendarDate(EastAsianLunisolarCalendar cal, in IsoDate isoDate) + { + var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); + + // If outside supported range, fall back to ISO-like fields + if (dt < cal.MinSupportedDateTime || dt > cal.MaxSupportedDateTime) + { + throw new ArgumentOutOfRangeException(nameof(isoDate)); + } + + var year = cal.GetYear(dt); + var ordinalMonth = cal.GetMonth(dt); + var day = cal.GetDayOfMonth(dt); + var leapMonth = cal.GetLeapMonth(year); + + bool isLeapMonth; + string monthCode; + + if (leapMonth > 0 && ordinalMonth >= leapMonth) + { + if (ordinalMonth == leapMonth) + { + var displayMonth = leapMonth - 1; + isLeapMonth = true; + monthCode = $"M{displayMonth:D2}L"; + } + else + { + var displayMonth = ordinalMonth - 1; + isLeapMonth = false; + monthCode = $"M{displayMonth:D2}"; + } + } + else + { + isLeapMonth = false; + monthCode = $"M{ordinalMonth:D2}"; + } + + var monthsInYear = leapMonth > 0 ? 13 : 12; + var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); + var daysInYear = cal.GetDaysInYear(year); + var inLeapYear = leapMonth > 0; + + return new CalendarDate(year, ordinalMonth, monthCode, day, isLeapMonth, monthsInYear, daysInMonth, daysInYear, inLeapYear); + } + + private static IsoDate? LunisolarDateToIso(EastAsianLunisolarCalendar cal, int year, string? monthCode, int month, int day, string overflow) + { + int ordinalMonth; + + if (monthCode is not null) + { + var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; + var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); + + // Display month must be 1-12 (no calendar has display month > 12) + if (displayMonth < 1 || displayMonth > 12) + { + return null; + } + + var leapMonth = 0; + try + { + leapMonth = cal.GetLeapMonth(year); + } + catch + { + // Year out of range + } + + if (isLeap) + { + if (leapMonth <= 0 || leapMonth - 1 != displayMonth) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + return null; + } + + // Constrain: use the non-leap version of the month + ordinalMonth = leapMonth > 0 && displayMonth >= leapMonth ? displayMonth + 1 : displayMonth; + } + else + { + ordinalMonth = leapMonth; + } + } + else + { + if (leapMonth > 0 && displayMonth >= leapMonth) + { + ordinalMonth = displayMonth + 1; + } + else + { + ordinalMonth = displayMonth; + } + } + + // Validate month matches ordinal if both provided + if (month > 0 && month != ordinalMonth) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + return null; + } + + // monthCode takes precedence + } + } + else + { + ordinalMonth = month; + } + + // Constrain ordinal month to valid range + var maxMonths = 12; + try + { + maxMonths = cal.GetLeapMonth(year) > 0 ? 13 : 12; + } + catch + { + // Year out of range + } + + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + ordinalMonth = Clamp(ordinalMonth, 1, maxMonths); + } + else if (ordinalMonth < 1 || ordinalMonth > maxMonths) + { + return null; + } + + // Constrain day + int maxDay; + try + { + maxDay = cal.GetDaysInMonth(year, ordinalMonth); + } + catch + { + maxDay = 30; + } + + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + day = Clamp(day, 1, maxDay); + } + else if (day < 1 || day > maxDay) + { + return null; + } + + try + { + var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); + return new IsoDate(dt.Year, dt.Month, dt.Day); + } + catch + { + return null; + } + } + + #endregion + + #region Hebrew Calendar + + private static CalendarDate HebrewToCalendarDate(in IsoDate isoDate) + { + var cal = HebrewCal; + var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); + + if (dt < cal.MinSupportedDateTime || dt > cal.MaxSupportedDateTime) + { + throw new ArgumentOutOfRangeException(nameof(isoDate)); + } + + var year = cal.GetYear(dt); + var ordinalMonth = cal.GetMonth(dt); + var day = cal.GetDayOfMonth(dt); + var isLeapYear = cal.IsLeapYear(year); + + bool isLeapMonth; + string monthCode; + + if (isLeapYear) + { + // Leap year: 13 months + // ordinals 1-5 → M01-M05 + // ordinal 6 → M05L (Adar I, the intercalary month) + // ordinals 7-13 → M06-M12 + if (ordinalMonth <= 5) + { + monthCode = $"M{ordinalMonth:D2}"; + isLeapMonth = false; + } + else if (ordinalMonth == 6) + { + monthCode = "M05L"; + isLeapMonth = true; + } + else + { + monthCode = $"M{ordinalMonth - 1:D2}"; + isLeapMonth = false; + } + } + else + { + // Non-leap year: 12 months, ordinals 1-12 → M01-M12 + monthCode = $"M{ordinalMonth:D2}"; + isLeapMonth = false; + } + + var monthsInYear = isLeapYear ? 13 : 12; + var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); + var daysInYear = cal.GetDaysInYear(year); + + return new CalendarDate(year, ordinalMonth, monthCode, day, isLeapMonth, monthsInYear, daysInMonth, daysInYear, isLeapYear); + } + + private static IsoDate? HebrewDateToIso(int year, string? monthCode, int month, int day, string overflow) + { + var cal = HebrewCal; + int ordinalMonth; + + if (monthCode is not null) + { + var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; + var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); + + // Display month must be 1-12 + if (displayMonth < 1 || displayMonth > 12) + { + return null; + } + + bool yearIsLeap; + try + { + yearIsLeap = cal.IsLeapYear(year); + } + catch + { + yearIsLeap = IsHebrewLeapYearAlgorithmic(year); + } + + if (isLeap) + { + if (!yearIsLeap || displayMonth != 5) + { + // M05L only exists in leap years + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + return null; + } + + // Constrain to M05 (non-leap version) + ordinalMonth = 5; + } + else + { + ordinalMonth = 6; // M05L → ordinal 6 + } + } + else + { + if (yearIsLeap && displayMonth >= 6) + { + ordinalMonth = displayMonth + 1; // M06 → ordinal 7, etc. + } + else + { + ordinalMonth = displayMonth; + } + } + + // Validate month matches ordinal if both provided + if (month > 0 && month != ordinalMonth) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + return null; + } + } + } + else + { + ordinalMonth = month; + } + + // Constrain ordinal month + int maxMonths; + try + { + maxMonths = cal.IsLeapYear(year) ? 13 : 12; + } + catch + { + maxMonths = 12; + } + + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + ordinalMonth = Clamp(ordinalMonth, 1, maxMonths); + } + else if (ordinalMonth < 1 || ordinalMonth > maxMonths) + { + return null; + } + + // Constrain day + int maxDay; + try + { + maxDay = cal.GetDaysInMonth(year, ordinalMonth); + } + catch + { + maxDay = 30; + } + + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + day = Clamp(day, 1, maxDay); + } + else if (day < 1 || day > maxDay) + { + return null; + } + + try + { + var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); + return new IsoDate(dt.Year, dt.Month, dt.Day); + } + catch + { + return null; + } + } + + #endregion + + #region Persian Calendar + + private static CalendarDate PersianToCalendarDate(in IsoDate isoDate) + { + var cal = PersianCal; + var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); + + if (dt < cal.MinSupportedDateTime || dt > cal.MaxSupportedDateTime) + { + throw new ArgumentOutOfRangeException(nameof(isoDate)); + } + + var year = cal.GetYear(dt); + var ordinalMonth = cal.GetMonth(dt); + var day = cal.GetDayOfMonth(dt); + var isLeapYear = cal.IsLeapYear(year); + + var monthCode = $"M{ordinalMonth:D2}"; + var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); + var daysInYear = cal.GetDaysInYear(year); + + return new CalendarDate(year, ordinalMonth, monthCode, day, false, 12, daysInMonth, daysInYear, isLeapYear); + } + + private static IsoDate? PersianDateToIso(int year, string? monthCode, int month, int day, string overflow) + { + var cal = PersianCal; + var ordinalMonth = month; + + if (monthCode is not null) + { + var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; + var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); + + if (isLeap) + { + // Persian has no leap months + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + return null; + } + + ordinalMonth = displayMonth; + } + else + { + ordinalMonth = displayMonth; + } + + if (month > 0 && month != ordinalMonth) + { + if (string.Equals(overflow, "reject", StringComparison.Ordinal)) + { + return null; + } + } + } + + // Constrain month + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + ordinalMonth = Clamp(ordinalMonth, 1, 12); + } + else if (ordinalMonth < 1 || ordinalMonth > 12) + { + return null; + } + + // Constrain day + int maxDay; + try + { + maxDay = cal.GetDaysInMonth(year, ordinalMonth); + } + catch + { + // Fallback: Persian months 1-6=31, 7-11=30, 12=29/30 + maxDay = ordinalMonth <= 6 ? 31 : ordinalMonth <= 11 ? 30 : 29; + } + + if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) + { + day = Clamp(day, 1, maxDay); + } + else if (day < 1 || day > maxDay) + { + return null; + } + + try + { + var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); + return new IsoDate(dt.Year, dt.Month, dt.Day); + } + catch + { + return null; + } + } + + #endregion +} diff --git a/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs b/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs index 05327598da..62f3672b62 100644 --- a/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs +++ b/Jint/Native/Temporal/PlainDate/PlainDateConstructor.cs @@ -161,13 +161,17 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa // Validate well-formedness (format) - this happens before year type validation monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCodeStr); - // If both month and monthCode are provided, they must match - if (month != 0 && month != monthFromCode) + // If both month and monthCode are provided, they must match (ISO only) + // For non-ISO calendars, ordinal month ≠ display month (e.g., month 5 = M04L) + if (!NonIsoCalendars.IsNonIsoCalendar(calendar) && month != 0 && month != monthFromCode) { Throw.RangeError(_realm, "month and monthCode do not match"); } - month = monthFromCode; + if (!NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + month = monthFromCode; + } } // 5. year - use eraYear if computed, otherwise read from property @@ -193,27 +197,30 @@ private JsPlainDate ToTemporalDateFromObjectWithOptions(ObjectInstance obj, JsVa // 6. Read options AFTER all fields (but BEFORE algorithmic validation) var overflow = TemporalHelpers.GetOverflowOption(_realm, options); - // Validate monthCode suitability - only for ISO/Gregorian calendars - if (monthCodeStr is not null && TemporalHelpers.IsGregorianBasedCalendar(calendar)) + // Validate monthCode suitability + if (monthCodeStr is not null) { - // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) - if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') + if (TemporalHelpers.IsGregorianBasedCalendar(calendar)) { - Throw.RangeError(_realm, $"Leap months are not valid for ISO 8601 calendar: {monthCodeStr}"); - } + // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) + if (monthCodeStr.Length == 4 && monthCodeStr[3] == 'L') + { + Throw.RangeError(_realm, $"Leap months are not valid for ISO 8601 calendar: {monthCodeStr}"); + } - if (monthFromCode < 1 || monthFromCode > 12) - { - Throw.RangeError(_realm, $"Month {monthFromCode} is not valid for ISO 8601 calendar"); + if (monthFromCode < 1 || monthFromCode > 12) + { + Throw.RangeError(_realm, $"Month {monthFromCode} is not valid for ISO 8601 calendar"); + } } } - if (month == 0) + if (month == 0 && monthCodeStr is null) { Throw.TypeError(_realm, "month or monthCode is required"); } - var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow, monthCodeStr); if (date is null) { Throw.RangeError(_realm, "Invalid date"); diff --git a/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs b/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs index 50bc54b850..662f849a81 100644 --- a/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs +++ b/Jint/Native/Temporal/PlainDate/PlainDatePrototype.cs @@ -88,25 +88,37 @@ private JsPlainDate ValidatePlainDate(JsValue thisObject) private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { var pd = ValidatePlainDate(thisObject); - var era = TemporalHelpers.CalendarEra(pd.Calendar, pd.IsoDate.Year); + var era = TemporalHelpers.CalendarEra(pd.Calendar, pd.IsoDate); return era is not null ? new JsString(era) : Undefined; } private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { var pd = ValidatePlainDate(thisObject); - var eraYear = TemporalHelpers.CalendarEraYear(pd.Calendar, pd.IsoDate.Year); + var eraYear = TemporalHelpers.CalendarEraYear(pd.Calendar, pd.IsoDate); return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) { var pd = ValidatePlainDate(thisObject); - return JsNumber.Create(TemporalHelpers.CalendarYear(pd.Calendar, pd.IsoDate.Year)); + return JsNumber.Create(TemporalHelpers.CalendarYear(pd.Calendar, pd.IsoDate)); + } + private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonth(pd.Calendar, pd.IsoDate)); + } + private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return new JsString(TemporalHelpers.CalendarMonthCode(pd.Calendar, pd.IsoDate)); + } + private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDay(pd.Calendar, pd.IsoDate)); } - private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Month); - private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainDate(thisObject).IsoDate.Month:D2}"); - private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.Day); private JsNumber GetDayOfWeek(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DayOfWeek()); private JsNumber GetDayOfYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DayOfYear()); private JsValue GetWeekOfYear(JsValue thisObject, JsCallArguments arguments) @@ -127,16 +139,28 @@ private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments) return JsNumber.Create(7); } - private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DaysInMonth()); - private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDate(thisObject).IsoDate.DaysInYear()); + private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDaysInMonth(pd.Calendar, pd.IsoDate)); + } + private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDaysInYear(pd.Calendar, pd.IsoDate)); + } private JsNumber GetMonthsInYear(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainDate(thisObject); - return JsNumber.Create(12); + var pd = ValidatePlainDate(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonthsInYear(pd.Calendar, pd.IsoDate)); } - private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) => IsoDate.IsLeapYear(ValidatePlainDate(thisObject).IsoDate.Year) ? JsBoolean.True : JsBoolean.False; + private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) + { + var pd = ValidatePlainDate(thisObject); + return TemporalHelpers.CalendarInLeapYear(pd.Calendar, pd.IsoDate) ? JsBoolean.True : JsBoolean.False; + } /// /// https://tc39.es/proposal-temporal/#sec-temporal.plaindate.prototype.with @@ -228,10 +252,49 @@ private JsPlainDate With(JsValue thisObject, JsCallArguments arguments) var options = GetOptionsObject(optionsArg); var overflow = TemporalHelpers.GetOverflowOption(_realm, (JsValue?) options ?? JsValue.Undefined); - // Merge with existing date values + // Handle non-ISO calendars + if (NonIsoCalendars.IsNonIsoCalendar(plainDate.Calendar)) + { + // Get current calendar fields + var calDate = NonIsoCalendars.IsoToCalendarDate(plainDate.Calendar, plainDate.IsoDate); + + // Merge with provided fields + var finalCalYear = year ?? calDate.Year; + var finalCalDay = day ?? calDate.Day; + + // monthCode: use explicit if provided, else default from current date + // BUT: if month is explicitly provided without monthCode, don't use default monthCode + // (month takes precedence as ordinal month) + string? finalMonthCode; + if (monthCode is not null) + { + TemporalHelpers.ParseMonthCode(_realm, monthCode); + finalMonthCode = monthCode; + } + else if (month.HasValue) + { + // Explicit month provided, no monthCode → use month as ordinal, no monthCode + finalMonthCode = null; + } + else + { + finalMonthCode = calDate.MonthCode; + } + + var date = TemporalHelpers.CalendarDateToISO(_realm, plainDate.Calendar, finalCalYear, + month ?? 0, finalCalDay, overflow, finalMonthCode); + if (date is null) + { + Throw.RangeError(_realm, "Invalid date"); + } + + return _constructor.Construct(date.Value, plainDate.Calendar); + } + + // Merge with existing date values (ISO/Gregorian path) var finalYear = year ?? plainDate.IsoDate.Year; var finalMonth = month ?? plainDate.IsoDate.Month; - var finalDay = day ?? plainDate.IsoDate.Day; + var finalDayVal = day ?? plainDate.IsoDate.Day; // Handle monthCode - if provided, validate and use it // Validation happens AFTER options are read @@ -266,23 +329,19 @@ private JsPlainDate With(JsValue thisObject, JsCallArguments arguments) } // Check for fundamentally invalid values - // - Year out of supported range (-271821 to 275760) - // - Month or day <= 0 (negative or zero) - // These throw RangeError even with overflow="constrain" - // But allow constrainable values like month=13 or day=32 - if (finalYear < -271821 || finalYear > 275760 || finalMonth < 1 || finalDay < 1) + if (finalYear < -271821 || finalYear > 275760 || finalMonth < 1 || finalDayVal < 1) { Throw.RangeError(_realm, "Invalid date"); } // Apply regulation with user's overflow option - var date = TemporalHelpers.RegulateIsoDate(finalYear, finalMonth, finalDay, overflow); - if (date is null) + var date2 = TemporalHelpers.RegulateIsoDate(finalYear, finalMonth, finalDayVal, overflow); + if (date2 is null) { Throw.RangeError(_realm, "Invalid date"); } - return _constructor.Construct(date.Value, plainDate.Calendar); + return _constructor.Construct(date2.Value, plainDate.Calendar); } @@ -335,15 +394,25 @@ private JsPlainDate Subtract(JsValue thisObject, JsCallArguments arguments) private JsPlainDate AddDurationToDate(JsPlainDate plainDate, DurationRecord duration, string overflow) { - var newDate = TemporalHelpers.AddDurationToDate(plainDate.IsoDate, duration); + // Balance time units into days first (for PlainDate, time components become extra days) + var totalNanoseconds = TemporalHelpers.TimeDurationFromComponents(duration); + var balancedDays = (long) (totalNanoseconds / TemporalHelpers.NanosecondsPerDay); + + // Create a date-only duration with balanced days + var dateDuration = new DurationRecord( + duration.Years, duration.Months, duration.Weeks, + duration.Days + balancedDays, 0, 0, 0, 0, 0, 0); + + // Use calendar-aware date addition + var newDate = TemporalHelpers.CalendarDateAdd(_realm, plainDate.Calendar, plainDate.IsoDate, dateDuration, overflow); - var regulated = TemporalHelpers.RegulateIsoDate(newDate.Year, newDate.Month, newDate.Day, overflow); - if (regulated is null) + // Validate the result is within Temporal's representable range + if (!TemporalHelpers.IsValidIsoDateTime(newDate.Year, newDate.Month, newDate.Day)) { - Throw.RangeError(_realm, "Invalid date after addition"); + Throw.RangeError(_realm, "Date is outside the valid range"); } - return _constructor.Construct(regulated.Value, plainDate.Calendar); + return _constructor.Construct(newDate, plainDate.Calendar); } /// diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs index da331ba054..27ac313543 100644 --- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs +++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimeConstructor.cs @@ -371,19 +371,25 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue } // Now validate and combine month/monthCode - // Validate: both month and monthCode provided - they must match - if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value) + if (monthCodeStr is not null) { - Throw.RangeError(_realm, "month and monthCode must match"); - } + if (TemporalHelpers.IsGregorianBasedCalendar(calendar)) + { + // Validate: both month and monthCode provided - they must match + if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value) + { + Throw.RangeError(_realm, "month and monthCode must match"); + } - // Use whichever is provided - if (monthFromCode.HasValue) - { - month = monthFromCode.Value; + // For ISO, use parsed month number + if (monthFromCode.HasValue) + { + month = monthFromCode.Value; + } + } } - if (month == 0) + if (month == 0 && monthCodeStr is null) { Throw.TypeError(_realm, "month or monthCode is required"); } @@ -394,7 +400,7 @@ private JsPlainDateTime ToTemporalDateTimeFromFields(ObjectInstance obj, JsValue Throw.RangeError(_realm, "Date is outside valid range"); } - var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow, monthCodeStr); if (date is null) { Throw.RangeError(_realm, "Invalid date"); diff --git a/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs b/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs index 0a30f467aa..9e8c1f0682 100644 --- a/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs +++ b/Jint/Native/Temporal/PlainDateTime/PlainDateTimePrototype.cs @@ -96,25 +96,37 @@ private JsPlainDateTime ValidatePlainDateTime(JsValue thisObject) private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { var pdt = ValidatePlainDateTime(thisObject); - var era = TemporalHelpers.CalendarEra(pdt.Calendar, pdt.IsoDateTime.Year); + var era = TemporalHelpers.CalendarEra(pdt.Calendar, pdt.IsoDateTime.Date); return era is not null ? new JsString(era) : Undefined; } private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { var pdt = ValidatePlainDateTime(thisObject); - var eraYear = TemporalHelpers.CalendarEraYear(pdt.Calendar, pdt.IsoDateTime.Year); + var eraYear = TemporalHelpers.CalendarEraYear(pdt.Calendar, pdt.IsoDateTime.Date); return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) { var pdt = ValidatePlainDateTime(thisObject); - return JsNumber.Create(TemporalHelpers.CalendarYear(pdt.Calendar, pdt.IsoDateTime.Year)); + return JsNumber.Create(TemporalHelpers.CalendarYear(pdt.Calendar, pdt.IsoDateTime.Date)); + } + private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonth(pdt.Calendar, pdt.IsoDateTime.Date)); + } + private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return new JsString(TemporalHelpers.CalendarMonthCode(pdt.Calendar, pdt.IsoDateTime.Date)); + } + private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDay(pdt.Calendar, pdt.IsoDateTime.Date)); } - private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Month); - private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainDateTime(thisObject).IsoDateTime.Month:D2}"); - private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Day); private JsNumber GetHour(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Hour); private JsNumber GetMinute(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Minute); private JsNumber GetSecond(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Second); @@ -141,16 +153,28 @@ private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments) return JsNumber.Create(7); } - private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.DaysInMonth()); - private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainDateTime(thisObject).IsoDateTime.Date.DaysInYear()); + private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDaysInMonth(pdt.Calendar, pdt.IsoDateTime.Date)); + } + private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDaysInYear(pdt.Calendar, pdt.IsoDateTime.Date)); + } private JsNumber GetMonthsInYear(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainDateTime(thisObject); - return JsNumber.Create(12); + var pdt = ValidatePlainDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonthsInYear(pdt.Calendar, pdt.IsoDateTime.Date)); } - private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) => IsoDate.IsLeapYear(ValidatePlainDateTime(thisObject).IsoDateTime.Year) ? JsBoolean.True : JsBoolean.False; + private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) + { + var pdt = ValidatePlainDateTime(thisObject); + return TemporalHelpers.CalendarInLeapYear(pdt.Calendar, pdt.IsoDateTime.Date) ? JsBoolean.True : JsBoolean.False; + } /// /// https://tc39.es/proposal-temporal/#sec-temporal.plaindatetime.prototype.with @@ -204,7 +228,12 @@ private JsPlainDateTime With(JsValue thisObject, JsCallArguments arguments) any = true; day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, v); } - else { day = plainDateTime.IsoDateTime.Day; } + else + { + day = NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar) + ? TemporalHelpers.CalendarDay(plainDateTime.Calendar, plainDateTime.IsoDateTime.Date) + : plainDateTime.IsoDateTime.Day; + } v = obj.Get("hour"); int hour; @@ -244,12 +273,19 @@ private JsPlainDateTime With(JsValue thisObject, JsCallArguments arguments) v = obj.Get("month"); int month; + var monthExplicit = false; if (!v.IsUndefined()) { any = true; + monthExplicit = true; month = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, v); } - else { month = plainDateTime.IsoDateTime.Month; } + else + { + month = NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar) + ? TemporalHelpers.CalendarMonth(plainDateTime.Calendar, plainDateTime.IsoDateTime.Date) + : plainDateTime.IsoDateTime.Month; + } v = obj.Get("monthCode"); string? monthCode = null; @@ -258,6 +294,10 @@ private JsPlainDateTime With(JsValue thisObject, JsCallArguments arguments) any = true; monthCode = TypeConverter.ToString(v); } + else if (NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar) && !monthExplicit) + { + monthCode = TemporalHelpers.CalendarMonthCode(plainDateTime.Calendar, plainDateTime.IsoDateTime.Date); + } v = obj.Get("nanosecond"); int nanosecond; @@ -284,7 +324,12 @@ private JsPlainDateTime With(JsValue thisObject, JsCallArguments arguments) any = true; year = ConvertToInteger(v, "year"); } - else { year = plainDateTime.IsoDateTime.Year; } + else + { + year = NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar) + ? TemporalHelpers.CalendarYear(plainDateTime.Calendar, plainDateTime.IsoDateTime.Date) + : plainDateTime.IsoDateTime.Year; + } if (!any) { @@ -299,33 +344,47 @@ private JsPlainDateTime With(JsValue thisObject, JsCallArguments arguments) int? monthFromCode = null; if (monthCode is not null) { - if (monthCode.Length >= 2 && monthCode[0] == 'M') + // Use ParseMonthCode which properly handles "L" suffix for leap months + monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCode); + + // ISO/Gregorian calendars don't support leap months + if (!NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar)) { - if (int.TryParse(monthCode.AsSpan(1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMonth)) + if (monthCode.Length == 4 && monthCode[3] == 'L') { - monthFromCode = parsedMonth; + Throw.RangeError(_realm, $"Leap months are not valid for calendar: {monthCode}"); } - } - if (!monthFromCode.HasValue) - { - Throw.RangeError(_realm, $"Invalid monthCode: {monthCode}"); + if (monthFromCode.Value < 1 || monthFromCode.Value > 12) + { + Throw.RangeError(_realm, $"Month {monthFromCode.Value} is not valid for calendar"); + } } } - // Validate month/monthCode consistency - if (monthFromCode.HasValue && month != plainDateTime.IsoDateTime.Month && month != monthFromCode.Value) + // Validate month/monthCode consistency (ISO calendars only - non-ISO ordinal ≠ display) + if (!NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar) && + monthFromCode.HasValue && month != plainDateTime.IsoDateTime.Month && month != monthFromCode.Value) { Throw.RangeError(_realm, "month and monthCode must match"); } - // Use monthCode if provided - if (monthFromCode.HasValue) + // Use monthCode if provided (for ISO calendars) + if (monthFromCode.HasValue && !NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar)) { month = monthFromCode.Value; } - var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); + IsoDate? date; + if (NonIsoCalendars.IsNonIsoCalendar(plainDateTime.Calendar)) + { + date = TemporalHelpers.CalendarDateToISO(_realm, plainDateTime.Calendar, year, month, day, overflow, monthCode); + } + else + { + date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); + } + if (date is null) { Throw.RangeError(_realm, "Invalid date"); @@ -439,19 +498,15 @@ private JsPlainDateTime AddDurationToDateTime(JsPlainDateTime plainDateTime, Dur Throw.RangeError(_realm, "Date is outside the valid range"); } - // Step 4: Add date part with adjusted days - var newDate = TemporalHelpers.AddDurationToDateCore(plainDateTime.IsoDateTime.Date, duration, (long) dayOverflow); - - // Step 5: Regulate and validate the result - var regulated = TemporalHelpers.RegulateIsoDate(newDate.Year, newDate.Month, newDate.Day, overflow); - if (regulated is null) - { - Throw.RangeError(_realm, "Invalid date after addition"); - } + // Step 4: Add date part using calendar-aware arithmetic + var adjustedDuration = new DurationRecord( + duration.Years, duration.Months, duration.Weeks, + duration.Days + (long) dayOverflow, 0, 0, 0, 0, 0, 0); + var regulated = TemporalHelpers.CalendarDateAdd(_realm, plainDateTime.Calendar, plainDateTime.IsoDateTime.Date, adjustedDuration, overflow); // Step 6: Construct validates ISODateTimeWithinLimits var newTime = IsoTime.FromNanoseconds((long) timeNs); - return _constructor.Construct(new IsoDateTime(regulated.Value, newTime), plainDateTime.Calendar); + return _constructor.Construct(new IsoDateTime(regulated, newTime), plainDateTime.Calendar); } /// diff --git a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs index 7e185bf069..a57cf0afa2 100644 --- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs +++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayConstructor.cs @@ -293,19 +293,20 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue Throw.TypeError(_realm, "monthCode is required for non-ISO calendars when year is not provided"); } - // Validate: both month and monthCode provided - they must match - if (month != 0 && monthFromCode.HasValue && month != monthFromCode.Value) + // Validate: both month and monthCode provided - they must match (ISO only) + // For non-ISO calendars, ordinal month ≠ display month (e.g., month 5 = M04L) + if (!NonIsoCalendars.IsNonIsoCalendar(calendar) && month != 0 && monthFromCode.HasValue && month != monthFromCode.Value) { Throw.RangeError(_realm, "month and monthCode must match"); } - // Use whichever is provided - if (monthFromCode.HasValue) + // Use whichever is provided (ISO only - non-ISO uses monthCode in CalendarDateToISO) + if (!NonIsoCalendars.IsNonIsoCalendar(calendar) && monthFromCode.HasValue) { month = monthFromCode.Value; } - if (month == 0) + if (month == 0 && monthCodeStr is null) { Throw.TypeError(_realm, "month or monthCode is required"); } @@ -316,9 +317,9 @@ private JsPlainMonthDay ToTemporalMonthDayFromFields(ObjectInstance obj, JsValue // When year is not explicitly provided, find the calendar year that maps to ISO 1972 var calendarYear = yearExplicitlyProvided ? year - : TemporalHelpers.FindCalendarReferenceYear(calendar, 1972, month, day); + : TemporalHelpers.FindCalendarReferenceYear(calendar, 1972, month, day, monthCodeStr); - var calDate = TemporalHelpers.CalendarDateToISO(_realm, calendar, calendarYear, month, day, overflow); + var calDate = TemporalHelpers.CalendarDateToISO(_realm, calendar, calendarYear, month, day, overflow, monthCodeStr); if (calDate 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 ba4130b326..250a703315 100644 --- a/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs +++ b/Jint/Native/Temporal/PlainMonthDay/PlainMonthDayPrototype.cs @@ -61,8 +61,16 @@ private JsPlainMonthDay ValidatePlainMonthDay(JsValue thisObject) // Getters private JsString GetCalendarId(JsValue thisObject, JsCallArguments arguments) => new JsString(ValidatePlainMonthDay(thisObject).Calendar); - private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainMonthDay(thisObject).IsoDate.Month:D2}"); - private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainMonthDay(thisObject).IsoDate.Day); + private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) + { + var md = ValidatePlainMonthDay(thisObject); + return new JsString(TemporalHelpers.CalendarMonthCode(md.Calendar, md.IsoDate)); + } + private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) + { + var md = ValidatePlainMonthDay(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDay(md.Calendar, md.IsoDate)); + } /// /// https://tc39.es/proposal-temporal/#sec-temporal.plainmonthday.prototype.with @@ -101,13 +109,19 @@ private JsPlainMonthDay With(JsValue thisObject, JsCallArguments arguments) } // Read and convert properties in strict alphabetical order per spec: day, month, monthCode, year + var isNonIso = NonIsoCalendars.IsNonIsoCalendar(md.Calendar); + // 1. day var dayProp = obj.Get("day"); - var day = dayProp.IsUndefined() ? md.IsoDate.Day : TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayProp); + var day = dayProp.IsUndefined() + ? (isNonIso ? TemporalHelpers.CalendarDay(md.Calendar, md.IsoDate) : md.IsoDate.Day) + : TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayProp); // 2. month var monthProp = obj.Get("month"); - var month = monthProp.IsUndefined() ? md.IsoDate.Month : TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthProp); + var month = monthProp.IsUndefined() + ? (isNonIso ? TemporalHelpers.CalendarMonth(md.Calendar, md.IsoDate) : md.IsoDate.Month) + : TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthProp); // 3. monthCode - read but don't validate yet var monthCodeProp = obj.Get("monthCode"); @@ -160,39 +174,62 @@ private JsPlainMonthDay With(JsValue thisObject, JsCallArguments arguments) Throw.TypeError(_realm, "monthCode is required for non-ISO calendars"); } + // Default monthCode from calendar when not provided and month not explicitly set + if (monthCodeProp.IsUndefined() && monthProp.IsUndefined() && isNonIso) + { + monthCode = TemporalHelpers.CalendarMonthCode(md.Calendar, md.IsoDate); + } + // NOW validate monthCode (after options are read) int? monthFromCode = null; if (monthCode is not null) { monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCode); - // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) - if (monthCode.Length == 4 && monthCode[3] == 'L') + // For ISO/Gregorian calendars: validate monthCode is valid (01-12, no leap months) + if (!isNonIso) { - Throw.RangeError(_realm, $"Leap months are not valid for ISO 8601 calendar: {monthCode}"); - } + if (monthCode.Length == 4 && monthCode[3] == 'L') + { + Throw.RangeError(_realm, $"Leap months are not valid for calendar: {monthCode}"); + } - if (monthFromCode.Value < 1 || monthFromCode.Value > 12) - { - Throw.RangeError(_realm, $"Month {monthFromCode.Value} is not valid for ISO 8601 calendar"); + if (monthFromCode.Value < 1 || monthFromCode.Value > 12) + { + Throw.RangeError(_realm, $"Month {monthFromCode.Value} is not valid for calendar"); + } } } - // Validate month/monthCode consistency - if (monthFromCode.HasValue && !monthProp.IsUndefined() && month != monthFromCode.Value) + // Validate month/monthCode consistency (ISO only) + if (!isNonIso && monthFromCode.HasValue && !monthProp.IsUndefined() && month != monthFromCode.Value) { Throw.RangeError(_realm, "month and monthCode must match"); } - // Use monthCode if provided - if (monthFromCode.HasValue) + // Use monthCode if provided (ISO only - non-ISO handled by CalendarDateToISO) + if (!isNonIso && monthFromCode.HasValue) { month = monthFromCode.Value; } - // Validate using the provided year (important for leap day validation) - // For PlainMonthDay with iso8601 calendar, the year is only used for overflow, - // not range-checked (per spec CalendarMonthDayToISOReferenceDate) + if (isNonIso) + { + // For non-ISO calendars, use calendar-aware date construction + var calYear = isNonIso && yearProp.IsUndefined() + ? TemporalHelpers.CalendarYear(md.Calendar, md.IsoDate) + : year; + var calDate = TemporalHelpers.CalendarDateToISO(_realm, md.Calendar, calYear, + monthProp.IsUndefined() ? 0 : month, day, overflow, monthCode); + if (calDate is null) + { + Throw.RangeError(_realm, "Invalid month-day"); + } + + return _constructor.Construct(calDate.Value, md.Calendar); + } + + // ISO path: validate using the provided year var skipRangeCheck = string.Equals(md.Calendar, "iso8601", StringComparison.Ordinal); var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow, skipRangeCheck); if (date is null) @@ -200,8 +237,6 @@ private JsPlainMonthDay With(JsValue thisObject, JsCallArguments arguments) Throw.RangeError(_realm, "Invalid month-day"); } - // Choose reference year for result (per spec: not the same as the validation year) - // Use 1972 (a leap year) to ensure Feb 29 is always valid var referenceYear = 1972; var resultDate = new IsoDate(referenceYear, date.Value.Month, date.Value.Day); diff --git a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs index cca58d790d..81917da0eb 100644 --- a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs +++ b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthConstructor.cs @@ -281,13 +281,16 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance // Validate well-formedness (format) - this happens before year type validation monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCodeStr); - // If both month and monthCode are provided, they must match - if (month != 0 && month != monthFromCode.Value) + // If both month and monthCode are provided, they must match (ISO only) + if (!NonIsoCalendars.IsNonIsoCalendar(calendar) && month != 0 && month != monthFromCode.Value) { Throw.RangeError(_realm, "month and monthCode must match"); } - month = monthFromCode.Value; + if (!NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + month = monthFromCode.Value; + } } // 5. year - use eraYear if computed, otherwise read from property @@ -327,7 +330,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance } // At least one of month or monthCode is required - if (month == 0) + if (month == 0 && monthCodeStr is null) { Throw.TypeError(_realm, "month or monthCode is required"); } @@ -335,7 +338,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromObjectWithOptions(ObjectInstance // For non-ISO calendars, convert calendar year/month to ISO if (!TemporalHelpers.IsGregorianBasedCalendar(calendar)) { - var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow, monthCodeStr); if (date is null) { Throw.RangeError(_realm, "Invalid year-month"); @@ -431,13 +434,16 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin // Validate well-formedness (format) - this happens before year type validation monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCodeStr); - // If both month and monthCode are provided, they must match - if (month != 0 && month != monthFromCode.Value) + // If both month and monthCode are provided, they must match (ISO only) + if (!NonIsoCalendars.IsNonIsoCalendar(calendar) && month != 0 && month != monthFromCode.Value) { Throw.RangeError(_realm, "month and monthCode must match"); } - month = monthFromCode.Value; + if (!NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + month = monthFromCode.Value; + } } // 5. year - use eraYear if computed, otherwise read from property @@ -474,7 +480,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin } // At least one of month or monthCode is required - if (month == 0) + if (month == 0 && monthCodeStr is null) { Throw.TypeError(_realm, "month or monthCode is required"); } @@ -484,7 +490,7 @@ private JsPlainYearMonth ToTemporalYearMonthFromFields(ObjectInstance obj, strin // For non-ISO calendars, convert calendar year/month to ISO if (!TemporalHelpers.IsGregorianBasedCalendar(calendar)) { - var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, 1, overflow, monthCodeStr); if (date is null) { Throw.RangeError(_realm, "Invalid year-month"); diff --git a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs index 0e34364b77..c3848fbda9 100644 --- a/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs +++ b/Jint/Native/Temporal/PlainYearMonth/PlainYearMonthPrototype.cs @@ -75,28 +75,48 @@ private JsPlainYearMonth ValidatePlainYearMonth(JsValue thisObject) private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); - return JsNumber.Create(TemporalHelpers.CalendarYear(ym.Calendar, ym.IsoDate.Year)); + return JsNumber.Create(TemporalHelpers.CalendarYear(ym.Calendar, ym.IsoDate)); + } + private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) + { + var ym = ValidatePlainYearMonth(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonth(ym.Calendar, ym.IsoDate)); + } + private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) + { + var ym = ValidatePlainYearMonth(thisObject); + return new JsString(TemporalHelpers.CalendarMonthCode(ym.Calendar, ym.IsoDate)); + } + private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) + { + var ym = ValidatePlainYearMonth(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDaysInMonth(ym.Calendar, ym.IsoDate)); + } + private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) + { + var ym = ValidatePlainYearMonth(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDaysInYear(ym.Calendar, ym.IsoDate)); } - private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.Month); - private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => new JsString($"M{ValidatePlainYearMonth(thisObject).IsoDate.Month:D2}"); - private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.DaysInMonth()); - private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidatePlainYearMonth(thisObject).IsoDate.DaysInYear()); private JsNumber GetMonthsInYear(JsValue thisObject, JsCallArguments arguments) { - ValidatePlainYearMonth(thisObject); - return JsNumber.Create(12); + var ym = ValidatePlainYearMonth(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonthsInYear(ym.Calendar, ym.IsoDate)); + } + private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) + { + var ym = ValidatePlainYearMonth(thisObject); + return TemporalHelpers.CalendarInLeapYear(ym.Calendar, ym.IsoDate) ? JsBoolean.True : JsBoolean.False; } - private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) => IsoDate.IsLeapYear(ValidatePlainYearMonth(thisObject).IsoDate.Year) ? JsBoolean.True : JsBoolean.False; private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); - var eraYear = TemporalHelpers.CalendarEraYear(ym.Calendar, ym.IsoDate.Year); + var eraYear = TemporalHelpers.CalendarEraYear(ym.Calendar, ym.IsoDate); return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { var ym = ValidatePlainYearMonth(thisObject); - var era = TemporalHelpers.CalendarEra(ym.Calendar, ym.IsoDate.Year); + var era = TemporalHelpers.CalendarEra(ym.Calendar, ym.IsoDate); return era is not null ? new JsString(era) : Undefined; } @@ -138,14 +158,20 @@ private JsPlainYearMonth With(JsValue thisObject, JsCallArguments arguments) // Read and convert properties in alphabetical order with immediate conversion per spec // Order: month, monthCode, year - var year = ym.IsoDate.Year; - var month = ym.IsoDate.Month; + var isNonIso = NonIsoCalendars.IsNonIsoCalendar(ym.Calendar); var monthProp = obj.Get("month"); + int month; if (!monthProp.IsUndefined()) { month = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, monthProp); } + else + { + month = isNonIso + ? TemporalHelpers.CalendarMonth(ym.Calendar, ym.IsoDate) + : ym.IsoDate.Month; + } var monthCodeProp = obj.Get("monthCode"); string? monthCode = null; @@ -176,12 +202,25 @@ private JsPlainYearMonth With(JsValue thisObject, JsCallArguments arguments) monthCode = TypeConverter.ToString(monthCodeProp); } } + else if (isNonIso && monthProp.IsUndefined()) + { + // Only default monthCode from the calendar when month was not explicitly provided; + // if the user set month explicitly, let month drive the conversion without monthCode + monthCode = TemporalHelpers.CalendarMonthCode(ym.Calendar, ym.IsoDate); + } var yearProp = obj.Get("year"); + int year; if (!yearProp.IsUndefined()) { year = TemporalHelpers.ToIntegerWithTruncationAsInt(_realm, yearProp); } + else + { + year = isNonIso + ? TemporalHelpers.CalendarYear(ym.Calendar, ym.IsoDate) + : ym.IsoDate.Year; + } // Validate that at least one temporal field was provided (IsPartialTemporalObject) if (monthProp.IsUndefined() && monthCodeProp.IsUndefined() && yearProp.IsUndefined()) @@ -192,7 +231,30 @@ private JsPlainYearMonth With(JsValue thisObject, JsCallArguments arguments) // Read options BEFORE any validation (per spec) var overflow = TemporalHelpers.GetOverflowOption(_realm, options); - // NOW validate monthCode (after options are read) + // Handle non-ISO calendars via CalendarDateToISO + if (isNonIso) + { + // Validate monthCode well-formedness if explicitly provided + if (!monthCodeProp.IsUndefined() && monthCode is not null) + { + TemporalHelpers.ParseMonthCode(_realm, monthCode); + } + + var date = TemporalHelpers.CalendarDateToISO(_realm, ym.Calendar, year, month, 1, overflow, monthCode); + if (date is null) + { + Throw.RangeError(_realm, "Invalid year-month"); + } + + if (!TemporalHelpers.ISOYearMonthWithinLimits(date.Value.Year, date.Value.Month)) + { + Throw.RangeError(_realm, "Year-month is outside the representable range"); + } + + return _constructor.Construct(date.Value, ym.Calendar); + } + + // ISO calendar path int? parsedMonthFromCode = null; if (monthCode is not null) { @@ -290,29 +352,17 @@ private JsPlainYearMonth AddDurationToYearMonth(JsPlainYearMonth ym, DurationRec var intermediateDate = new IsoDate(ym.IsoDate.Year, ym.IsoDate.Month, 1); TemporalHelpers.CheckISODaysRange(_realm, intermediateDate); - // Add years and months - var currentYear = ym.IsoDate.Year + (int) duration.Years; - var currentMonth = ym.IsoDate.Month + (int) duration.Months; - - // Normalize months - while (currentMonth > 12) - { - currentMonth -= 12; - currentYear++; - } - while (currentMonth < 1) - { - currentMonth += 12; - currentYear--; - } + // Use calendar-aware date addition + var yearMonthDuration = new DurationRecord(duration.Years, duration.Months, 0, 0, 0, 0, 0, 0, 0, 0); + var resultDate = TemporalHelpers.CalendarDateAdd(_realm, ym.Calendar, ym.IsoDate, yearMonthDuration, overflow); // Validate the result is within Temporal's representable range - if (!TemporalHelpers.ISOYearMonthWithinLimits(currentYear, currentMonth)) + if (!TemporalHelpers.ISOYearMonthWithinLimits(resultDate.Year, resultDate.Month)) { Throw.RangeError(_realm, "Year-month result is outside the representable range"); } - return _constructor.Construct(new IsoDate(currentYear, currentMonth, 1), ym.Calendar); + return _constructor.Construct(resultDate, ym.Calendar); } /// @@ -618,7 +668,21 @@ private JsPlainDate ToPlainDate(JsValue thisObject, JsCallArguments arguments) } var day = TemporalHelpers.ToPositiveIntegerWithTruncation(_realm, dayProp); - var date = TemporalHelpers.RegulateIsoDate(ym.IsoDate.Year, ym.IsoDate.Month, day, "constrain"); + + IsoDate? date; + if (NonIsoCalendars.IsNonIsoCalendar(ym.Calendar)) + { + // For non-ISO calendars, get the calendar year/month and combine with the provided day + var calYear = TemporalHelpers.CalendarYear(ym.Calendar, ym.IsoDate); + var calMonth = TemporalHelpers.CalendarMonth(ym.Calendar, ym.IsoDate); + var calMonthCode = TemporalHelpers.CalendarMonthCode(ym.Calendar, ym.IsoDate); + date = TemporalHelpers.CalendarDateToISO(_realm, ym.Calendar, calYear, calMonth, day, "constrain", calMonthCode); + } + else + { + date = TemporalHelpers.RegulateIsoDate(ym.IsoDate.Year, ym.IsoDate.Month, day, "constrain"); + } + if (date is null) { Throw.RangeError(_realm, "Invalid date"); diff --git a/Jint/Native/Temporal/TemporalHelpers.cs b/Jint/Native/Temporal/TemporalHelpers.cs index 0302077472..5c3a09ad9b 100644 --- a/Jint/Native/Temporal/TemporalHelpers.cs +++ b/Jint/Native/Temporal/TemporalHelpers.cs @@ -1612,13 +1612,18 @@ public static bool ISOYearMonthWithinLimits(int year, int month) /// For non-ISO calendars, the input fields are in the calendar's system /// and need to be converted to the proleptic Gregorian (ISO 8601) calendar. /// - public static IsoDate? CalendarDateToISO(Realm realm, string calendar, int year, int month, int day, string overflow) + public static IsoDate? CalendarDateToISO(Realm realm, string calendar, int year, int month, int day, string overflow, string? monthCode = null) { if (calendar is "iso8601" or "gregory") { return RegulateIsoDate(year, month, day, overflow); } + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.CalendarDateToIso(calendar, year, monthCode, month, day, overflow); + } + if (calendar is "islamic-civil") { return IslamicCivilDateToISO(year, month, day, overflow); @@ -1638,8 +1643,13 @@ public static bool ISOYearMonthWithinLimits(int year, int month) /// converting (calendarYear, month, day) to ISO produces an ISO date with the given /// ISO reference year (typically 1972). /// - internal static int FindCalendarReferenceYear(string calendar, int isoReferenceYear, int month, int day) + internal static int FindCalendarReferenceYear(string calendar, int isoReferenceYear, int month, int day, string? monthCode = null) { + if (NonIsoCalendars.IsNonIsoCalendar(calendar) && monthCode is not null) + { + return NonIsoCalendars.FindCalendarReferenceYear(calendar, isoReferenceYear, monthCode, day); + } + if (calendar is "islamic-civil" or "islamic-tbla") { // Approximate Islamic year for a given ISO year @@ -1819,12 +1829,12 @@ internal static bool CalendarUsesEras(string calendar) case "buddhist": return "be"; case "japanese": - // Simplified: use CE/BCE for years outside specific eras + // Temporal uses 1873 as Meiji start (when Japan adopted Gregorian calendar) if (isoYear >= 2019) return "reiwa"; if (isoYear >= 1989) return "heisei"; if (isoYear >= 1926) return "showa"; if (isoYear >= 1912) return "taisho"; - if (isoYear >= 1868) return "meiji"; + if (isoYear >= 1873) return "meiji"; return isoYear >= 1 ? "ce" : "bce"; default: return null; @@ -1849,7 +1859,7 @@ internal static bool CalendarUsesEras(string calendar) if (isoYear >= 1989) return isoYear - 1988; if (isoYear >= 1926) return isoYear - 1925; if (isoYear >= 1912) return isoYear - 1911; - if (isoYear >= 1868) return isoYear - 1867; + if (isoYear >= 1873) return isoYear - 1872; return isoYear >= 1 ? isoYear : 1 - isoYear; default: return null; @@ -1877,6 +1887,189 @@ internal static int CalendarYear(string calendar, int isoYear) } } + /// + /// Returns the calendar-specific year for any calendar, including non-ISO calendars. + /// For non-ISO calendars, converts the ISO date to a calendar date first. + /// + internal static int CalendarYear(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return CalendarYear(calendar, isoDate.Year); + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).Year; + } + + return isoDate.Year; + } + + /// + /// Returns the calendar-specific ordinal month for a date. + /// + internal static int CalendarMonth(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return isoDate.Month; + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).Month; + } + + return isoDate.Month; + } + + /// + /// Returns the calendar-specific monthCode for a date (e.g., "M01", "M05L"). + /// + internal static string CalendarMonthCode(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return $"M{isoDate.Month:D2}"; + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).MonthCode; + } + + return $"M{isoDate.Month:D2}"; + } + + /// + /// Returns the calendar-specific day for a date. + /// + internal static int CalendarDay(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return isoDate.Day; + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).Day; + } + + return isoDate.Day; + } + + /// + /// Returns the number of days in the calendar month containing the given ISO date. + /// + internal static int CalendarDaysInMonth(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return isoDate.DaysInMonth(); + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).DaysInMonth; + } + + return isoDate.DaysInMonth(); + } + + /// + /// Returns the number of days in the calendar year containing the given ISO date. + /// + internal static int CalendarDaysInYear(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return isoDate.DaysInYear(); + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).DaysInYear; + } + + return isoDate.DaysInYear(); + } + + /// + /// Returns the number of months in the calendar year containing the given ISO date. + /// + internal static int CalendarMonthsInYear(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return 12; + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).MonthsInYear; + } + + return 12; + } + + /// + /// Returns whether the calendar year containing the given ISO date is a leap year. + /// + internal static bool CalendarInLeapYear(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return IsoDate.IsLeapYear(isoDate.Year); + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.IsoToCalendarDate(calendar, isoDate).InLeapYear; + } + + return IsoDate.IsLeapYear(isoDate.Year); + } + + /// + /// Returns the era string for any calendar, including non-ISO calendars. + /// + internal static string? CalendarEra(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return CalendarEra(calendar, isoDate.Year); + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + var calDate = NonIsoCalendars.IsoToCalendarDate(calendar, isoDate); + return NonIsoCalendars.CalendarEra(calendar, calDate); + } + + return CalendarEra(calendar, isoDate.Year); + } + + /// + /// Returns the eraYear for any calendar, including non-ISO calendars. + /// + internal static int? CalendarEraYear(string calendar, in IsoDate isoDate) + { + if (IsGregorianBasedCalendar(calendar)) + { + return CalendarEraYear(calendar, isoDate.Year); + } + + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + var calDate = NonIsoCalendars.IsoToCalendarDate(calendar, isoDate); + return NonIsoCalendars.CalendarEraYear(calendar, calDate); + } + + return CalendarEraYear(calendar, isoDate.Year); + } + /// /// Reads era and eraYear properties from a property bag for era-supporting calendars. /// Returns the computed year if era/eraYear are present, or null if they should be ignored. @@ -1933,8 +2126,13 @@ private static int ComputeYearFromEra(Realm realm, string calendar, string era, return 1 - eraYear; break; case "japanese": - if (era is "reiwa" or "heisei" or "showa" or "taisho" or "meiji" or "ce" or "bce") - return eraYear; // simplified + if (era is "reiwa") return eraYear + 2018; + if (era is "heisei") return eraYear + 1988; + if (era is "showa") return eraYear + 1925; + if (era is "taisho") return eraYear + 1911; + if (era is "meiji") return eraYear + 1872; + if (era is "ce" or "gregory") return eraYear; + if (era is "bce" or "gregory-inverse") return 1 - eraYear; break; case "roc": if (era is "roc" or "minguo") @@ -2263,6 +2461,31 @@ public static IsoDate CalendarDateAdd(Realm? realm, string calendar, IsoDate iso // Range validation happens at the caller (e.g., CreateTemporalDate, GetEpochNanosecondsFor). return result; } + else if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + try + { + // Add years and months using calendar-specific reckoning + var result = NonIsoCalendars.CalendarDateAdd(calendar, isoDate, (int) duration.Years, (int) duration.Months, overflow); + + // Add weeks and days using ISO arithmetic + var days = duration.Days + 7 * duration.Weeks; + if (days != 0) + { + result = AddDaysToISODate(result, days); + } + + return result; + } + catch (InvalidOperationException ex) when (string.Equals(ex.Message, "reject", StringComparison.Ordinal)) + { + if (realm != null) + Throw.RangeError(realm, "Invalid date after adding duration"); + else + throw new ArgumentException("Invalid date after adding duration", nameof(duration)); + return default; + } + } else { throw new NotSupportedException($"Calendar '{calendar}' not yet supported"); @@ -4143,16 +4366,19 @@ private static JsPlainDate CreatePlainDateFromFields(Realm realm, RelativeToFiel // Determine month from either month or monthCode int month; + string? monthCode = null; + var isNonIso = NonIsoCalendars.IsNonIsoCalendar(fields.Calendar); if (fields.Month.HasValue && !string.IsNullOrEmpty(fields.MonthCode)) { - // Both provided - parse monthCode and verify they match + // Both provided - verify they match (ISO only - non-ISO ordinal ≠ display) var parsedMonthCode = ParseMonthCode(realm, fields.MonthCode!); - if (parsedMonthCode != fields.Month.Value) + if (!isNonIso && parsedMonthCode != fields.Month.Value) { Throw.RangeError(realm, "month and monthCode do not match"); } month = fields.Month.Value; + monthCode = fields.MonthCode; } else if (fields.Month.HasValue) { @@ -4160,7 +4386,8 @@ private static JsPlainDate CreatePlainDateFromFields(Realm realm, RelativeToFiel } else if (!string.IsNullOrEmpty(fields.MonthCode)) { - month = ParseMonthCode(realm, fields.MonthCode!); + month = isNonIso ? 0 : ParseMonthCode(realm, fields.MonthCode!); + monthCode = fields.MonthCode; } else { @@ -4168,8 +4395,17 @@ private static JsPlainDate CreatePlainDateFromFields(Realm realm, RelativeToFiel return null!; } - // Regulate the date - var isoDate = RegulateIsoDate(fields.Year.Value, month, fields.Day.Value, "constrain"); + // Use calendar-aware date construction for non-ISO calendars + IsoDate? isoDate; + if (isNonIso) + { + isoDate = CalendarDateToISO(realm, fields.Calendar, fields.Year.Value, month, fields.Day.Value, "constrain", monthCode); + } + else + { + isoDate = RegulateIsoDate(fields.Year.Value, month, fields.Day.Value, "constrain"); + } + if (isoDate is null) { Throw.RangeError(realm, "Invalid date"); @@ -4202,15 +4438,18 @@ private static JsZonedDateTime CreateZonedDateTimeFromFields(Engine engine, Real // Determine month int month; + string? monthCode = null; + var isNonIso = NonIsoCalendars.IsNonIsoCalendar(fields.Calendar); if (fields.Month.HasValue && !string.IsNullOrEmpty(fields.MonthCode)) { var parsedMonthCode = ParseMonthCode(realm, fields.MonthCode!); - if (parsedMonthCode != fields.Month.Value) + if (!isNonIso && parsedMonthCode != fields.Month.Value) { Throw.RangeError(realm, "month and monthCode do not match"); } month = fields.Month.Value; + monthCode = fields.MonthCode; } else if (fields.Month.HasValue) { @@ -4218,7 +4457,8 @@ private static JsZonedDateTime CreateZonedDateTimeFromFields(Engine engine, Real } else if (!string.IsNullOrEmpty(fields.MonthCode)) { - month = ParseMonthCode(realm, fields.MonthCode!); + month = isNonIso ? 0 : ParseMonthCode(realm, fields.MonthCode!); + monthCode = fields.MonthCode; } else { @@ -4227,7 +4467,16 @@ private static JsZonedDateTime CreateZonedDateTimeFromFields(Engine engine, Real } // Regulate date and time - var isoDate = RegulateIsoDate(fields.Year.Value, month, fields.Day.Value, "constrain"); + IsoDate? isoDate; + if (isNonIso) + { + isoDate = CalendarDateToISO(realm, fields.Calendar, fields.Year.Value, month, fields.Day.Value, "constrain", monthCode); + } + else + { + isoDate = RegulateIsoDate(fields.Year.Value, month, fields.Day.Value, "constrain"); + } + if (isoDate is null) { Throw.RangeError(realm, "Invalid date"); @@ -6257,6 +6506,11 @@ internal static DurationRecord CalendarDateUntil(string calendar, IsoDate one, I // Step 3: For ISO and Gregorian-based calendars (same arithmetic) if (!IsGregorianBasedCalendar(calendar)) { + if (NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + return NonIsoCalendars.CalendarDateUntil(calendar, one, two, largestUnit); + } + throw new NotSupportedException($"Calendar '{calendar}' not yet supported"); } diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs index 167fdd3afe..7c43987962 100644 --- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs +++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimeConstructor.cs @@ -266,13 +266,16 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj, // Validate well-formedness (format) - this happens before year type validation monthFromCode = TemporalHelpers.ParseMonthCode(_realm, monthCodeStr); - // If both month and monthCode are provided, they must match - if (month != 0 && month != monthFromCode) + // If both month and monthCode are provided, they must match (ISO only) + if (!NonIsoCalendars.IsNonIsoCalendar(calendar) && month != 0 && month != monthFromCode) { Throw.RangeError(_realm, "month and monthCode do not match"); } - month = monthFromCode; + if (!NonIsoCalendars.IsNonIsoCalendar(calendar)) + { + month = monthFromCode; + } } // 9. nanosecond - read and convert immediately @@ -344,13 +347,13 @@ private JsZonedDateTime ToZonedDateTimeFromFieldsWithOptions(ObjectInstance obj, } } - if (month == 0) + if (month == 0 && monthCodeStr is null) { Throw.TypeError(_realm, "month or monthCode is required"); } // Regulate date (with calendar conversion for non-ISO calendars) - var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow); + var date = TemporalHelpers.CalendarDateToISO(_realm, calendar, year, month, day, overflow, monthCodeStr); if (date is null) { Throw.RangeError(_realm, "Invalid date"); diff --git a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs index 25872fc2a7..9ec7725c8e 100644 --- a/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs +++ b/Jint/Native/Temporal/ZonedDateTime/ZonedDateTimePrototype.cs @@ -113,7 +113,7 @@ private JsValue GetEra(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); var isoDateTime = zdt.GetIsoDateTime(); - var era = TemporalHelpers.CalendarEra(zdt.Calendar, isoDateTime.Year); + var era = TemporalHelpers.CalendarEra(zdt.Calendar, isoDateTime.Date); return era is not null ? new JsString(era) : Undefined; } @@ -121,7 +121,7 @@ private JsValue GetEraYear(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); var isoDateTime = zdt.GetIsoDateTime(); - var eraYear = TemporalHelpers.CalendarEraYear(zdt.Calendar, isoDateTime.Year); + var eraYear = TemporalHelpers.CalendarEraYear(zdt.Calendar, isoDateTime.Date); return eraYear.HasValue ? JsNumber.Create(eraYear.Value) : Undefined; } @@ -129,17 +129,26 @@ private JsNumber GetYear(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); var isoDateTime = zdt.GetIsoDateTime(); - return JsNumber.Create(TemporalHelpers.CalendarYear(zdt.Calendar, isoDateTime.Year)); + return JsNumber.Create(TemporalHelpers.CalendarYear(zdt.Calendar, isoDateTime.Date)); } - private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) => - JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Month); + private JsNumber GetMonth(JsValue thisObject, JsCallArguments arguments) + { + var zdt = ValidateZonedDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonth(zdt.Calendar, zdt.GetIsoDateTime().Date)); + } - private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) => - new JsString($"M{ValidateZonedDateTime(thisObject).GetIsoDateTime().Month:D2}"); + private JsString GetMonthCode(JsValue thisObject, JsCallArguments arguments) + { + var zdt = ValidateZonedDateTime(thisObject); + return new JsString(TemporalHelpers.CalendarMonthCode(zdt.Calendar, zdt.GetIsoDateTime().Date)); + } - private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) => - JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Day); + private JsNumber GetDay(JsValue thisObject, JsCallArguments arguments) + { + var zdt = ValidateZonedDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarDay(zdt.Calendar, zdt.GetIsoDateTime().Date)); + } private JsNumber GetHour(JsValue thisObject, JsCallArguments arguments) => JsNumber.Create(ValidateZonedDateTime(thisObject).GetIsoDateTime().Hour); @@ -221,30 +230,25 @@ private JsNumber GetDaysInWeek(JsValue thisObject, JsCallArguments arguments) private JsNumber GetDaysInMonth(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); - var date = zdt.GetIsoDateTime().Date; - return JsNumber.Create(IsoDate.IsoDateInMonth(date.Year, date.Month)); + return JsNumber.Create(TemporalHelpers.CalendarDaysInMonth(zdt.Calendar, zdt.GetIsoDateTime().Date)); } private JsNumber GetDaysInYear(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); - var year = zdt.GetIsoDateTime().Year; - var isLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); - return JsNumber.Create(isLeap ? 366 : 365); + return JsNumber.Create(TemporalHelpers.CalendarDaysInYear(zdt.Calendar, zdt.GetIsoDateTime().Date)); } private JsNumber GetMonthsInYear(JsValue thisObject, JsCallArguments arguments) { - ValidateZonedDateTime(thisObject); - return JsNumber.Create(12); // ISO 8601 always has 12 months + var zdt = ValidateZonedDateTime(thisObject); + return JsNumber.Create(TemporalHelpers.CalendarMonthsInYear(zdt.Calendar, zdt.GetIsoDateTime().Date)); } private JsBoolean GetInLeapYear(JsValue thisObject, JsCallArguments arguments) { var zdt = ValidateZonedDateTime(thisObject); - var year = zdt.GetIsoDateTime().Year; - var isLeap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); - return isLeap ? JsBoolean.True : JsBoolean.False; + return TemporalHelpers.CalendarInLeapYear(zdt.Calendar, zdt.GetIsoDateTime().Date) ? JsBoolean.True : JsBoolean.False; } private JsNumber GetHoursInDay(JsValue thisObject, JsCallArguments arguments) @@ -323,8 +327,20 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments) // Each field is read and immediately converted, defaulting to current value if undefined // Track if at least one property is present + var isNonIso = NonIsoCalendars.IsNonIsoCalendar(zdt.Calendar); + var dayValue = obj.Get("day"); - var day = dayValue.IsUndefined() ? current.Day : GetIntegerFromValue(dayValue); + int day; + if (!dayValue.IsUndefined()) + { + day = GetIntegerFromValue(dayValue); + } + else + { + day = isNonIso + ? TemporalHelpers.CalendarDay(zdt.Calendar, current.Date) + : current.Day; + } var hourValue = obj.Get("hour"); var hour = hourValue.IsUndefined() ? current.Hour : GetIntegerFromValue(hourValue); @@ -339,7 +355,19 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments) var minute = minuteValue.IsUndefined() ? current.Minute : GetIntegerFromValue(minuteValue); var monthValue = obj.Get("month"); - var month = monthValue.IsUndefined() ? current.Month : GetIntegerFromValue(monthValue); + int month; + var monthExplicit = false; + if (!monthValue.IsUndefined()) + { + monthExplicit = true; + month = GetIntegerFromValue(monthValue); + } + else + { + month = isNonIso + ? TemporalHelpers.CalendarMonth(zdt.Calendar, current.Date) + : current.Month; + } // monthCode - read in alphabetical order, convert to string but defer validation var monthCodeValue = obj.Get("monthCode"); @@ -373,6 +401,10 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments) monthCode = TypeConverter.ToString(monthCodeValue); } } + else if (isNonIso && !monthExplicit) + { + monthCode = TemporalHelpers.CalendarMonthCode(zdt.Calendar, current.Date); + } var nanosecondValue = obj.Get("nanosecond"); var nanosecond = nanosecondValue.IsUndefined() ? current.Nanosecond : GetIntegerFromValue(nanosecondValue); @@ -389,7 +421,17 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments) var second = secondValue.IsUndefined() ? current.Second : GetIntegerFromValue(secondValue); var yearValue = obj.Get("year"); - var year = yearValue.IsUndefined() ? current.Year : GetIntegerFromValue(yearValue); + int year; + if (!yearValue.IsUndefined()) + { + year = GetIntegerFromValue(yearValue); + } + else + { + year = isNonIso + ? TemporalHelpers.CalendarYear(zdt.Calendar, current.Date) + : current.Year; + } // Check that at least one property is present if (dayValue.IsUndefined() && hourValue.IsUndefined() && microsecondValue.IsUndefined() && @@ -440,15 +482,18 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments) // Parse monthCode for well-formedness parsedMonthCode = TemporalHelpers.ParseMonthCode(_realm, monthCode); - // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) - if (monthCode.Length == 4 && monthCode[3] == 'L') + if (!isNonIso) { - Throw.RangeError(_realm, $"Leap months are not valid for ISO 8601 calendar: {monthCode}"); - } + // For ISO 8601 calendar: validate monthCode is valid (01-12, no leap months) + if (monthCode.Length == 4 && monthCode[3] == 'L') + { + Throw.RangeError(_realm, $"Leap months are not valid for ISO 8601 calendar: {monthCode}"); + } - if (parsedMonthCode.Value < 1 || parsedMonthCode.Value > 12) - { - Throw.RangeError(_realm, $"Month {parsedMonthCode.Value} is not valid for ISO 8601 calendar"); + if (parsedMonthCode.Value < 1 || parsedMonthCode.Value > 12) + { + Throw.RangeError(_realm, $"Month {parsedMonthCode.Value} is not valid for ISO 8601 calendar"); + } } // Check if month property was explicitly provided (not just defaulted) @@ -462,12 +507,24 @@ private JsZonedDateTime With(JsValue thisObject, JsCallArguments arguments) } } - // Use monthCode if provided - month = parsedMonthCode.Value; + // Use monthCode if provided (for ISO calendars) + if (!isNonIso) + { + month = parsedMonthCode.Value; + } + } + + // Regulate date with user's overflow option + IsoDate? date; + if (isNonIso) + { + date = TemporalHelpers.CalendarDateToISO(_realm, zdt.Calendar, year, month, day, overflow, monthCode); + } + else + { + date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); } - // Regulate date and time with user's overflow option - var date = TemporalHelpers.RegulateIsoDate(year, month, day, overflow); if (date is null) { Throw.RangeError(_realm, "Invalid date");