diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index c11f44af7..826947138 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -36,9 +36,10 @@ import { RegExpPrototypeExec, SetPrototypeAdd, SetPrototypeValues, - StringPrototypeIndexOf, + StringPrototypeEndsWith, StringPrototypeNormalize, StringPrototypeReplace, + StringPrototypeSlice, StringPrototypeSplit, StringPrototypeToLowerCase, SymbolIterator, @@ -54,6 +55,7 @@ import { import Call from 'es-abstract/2024/Call.js'; import Type from 'es-abstract/2024/Type.js'; +import { assert } from './assert.mjs'; import * as ES from './ecmascript.mjs'; import { DefineIntrinsic } from './intrinsicclass.mjs'; import { CreateMonthCode, ParseMonthCode } from './monthcode.mjs'; @@ -371,43 +373,43 @@ function weekNumber(firstDayOfWeek, minimalDaysInFirstWeek, desiredDay, dayOfWee // about non-ISO calendars. However, non-ISO calendar implementation is subject // to change because these calendars are implementation-defined. -const eraInfo = { - buddhist: { +const eraInfoEntries = { + buddhist: ObjectEntries({ be: {} - }, - coptic: { + }), + coptic: ObjectEntries({ am: {} - }, - ethioaa: { + }), + ethioaa: ObjectEntries({ aa: { aliases: ['mundi'] } - }, - ethiopic: { + }), + ethiopic: ObjectEntries({ am: { aliases: ['incar'] }, aa: { aliases: ['mundi'] } - }, - gregory: { + }), + gregory: ObjectEntries({ ce: { aliases: ['ad'] }, bce: { aliases: ['bc'] } - }, - hebrew: { + }), + hebrew: ObjectEntries({ am: {} - }, - indian: { + }), + indian: ObjectEntries({ shaka: {} - }, - 'islamic-civil': { + }), + 'islamic-civil': ObjectEntries({ ah: {}, bh: {} - }, - 'islamic-tbla': { + }), + 'islamic-tbla': ObjectEntries({ ah: {}, bh: {} - }, - 'islamic-umalqura': { + }), + 'islamic-umalqura': ObjectEntries({ ah: {}, bh: {} - }, - japanese: { + }), + japanese: ObjectEntries({ reiwa: {}, heisei: {}, showa: {}, @@ -415,27 +417,26 @@ const eraInfo = { meiji: {}, ce: { aliases: ['ad'] }, bce: { aliases: ['bc'] } - }, - persian: { + }), + persian: ObjectEntries({ ap: {} - }, - roc: { + }), + roc: ObjectEntries({ roc: { aliases: ['minguo'] }, broc: { aliases: ['before-roc', 'minguo-qian'] } - } + }) }; function CalendarSupportsEra(calendar) { - return ObjectHasOwn(eraInfo, calendar); + return ObjectHasOwn(eraInfoEntries, calendar); } function CanonicalizeEraInCalendar(calendar, era) { - const eras = eraInfo[calendar]; - const entries = ObjectEntries(eras); + const entries = eraInfoEntries[calendar]; for (let ix = 0; ix < entries.length; ix++) { const canonicalName = entries[ix][0]; - const info = entries[ix][1]; if (era === canonicalName) return era; + const info = entries[ix][1]; if (info.aliases && Call(ArrayPrototypeIncludes, info.aliases, [era])) return canonicalName; } return undefined; @@ -445,18 +446,21 @@ function CanonicalizeEraInCalendar(calendar, era) { * This prototype implementation of non-ISO calendars makes many repeated calls * to Intl APIs which may be slow (e.g. >0.2ms). This trivial cache will speed * up these repeat accesses. Each cache instance is associated (via a WeakMap) - * to a specific Temporal object, which speeds up multiple calendar calls on the - * same Temporal object instance. No invalidation or pruning is necessary - * because each object's cache is thrown away when the object is GC-ed. + * to a specific ISO Date Record object, which speeds up multiple calendar calls + * on Temporal objects with the same ISO Date Record instance. No invalidation + * or pruning is necessary because each object's cache is thrown away when the + * object is GC-ed. */ class OneObjectCache { - constructor(cacheToClone = undefined) { + constructor(id, cacheToClone = undefined) { + this.id = id; this.map = new MapCtor(); this.calls = 0; this.now = now(); this.hits = 0; this.misses = 0; if (cacheToClone !== undefined) { + assert(cacheToClone.id === this.id, 'should not clone cache from a different calendar'); let i = 0; const entriesIterator = Call(MapPrototypeEntries, cacheToClone.map, []); for (;;) { @@ -495,19 +499,47 @@ class OneObjectCache { Call(WeakMapPrototypeSet, OneObjectCache.objectMap, [obj, this]); this.report(); } + // Cache keys are int32 + // int32 msb fedcba9876543210fedcba9876543210 lsb + // uyyyyyyyyyyyyyyyyyyyymmmmdddddff + // u = unused (1 bit) + // y = year + 280804 (20 bits; max is 564387) + // m = month (4 bits; max is 13) + // d = day (5 bits; max is 31) + // f = flags (indicates type of key, and overflow for calendar-to-ISO type) + // 00 = Chinese/Dangi month list + // 01 = ISO-to-calendar + // 10 = calendar-to-ISO, overflow constrain + // 11 = calendar-to-ISO, overflow reject + static privKey(year, month, day, flags) { + // -280804 is the earliest year number in any supported calendar (in this + // case, Hijri calendars) + const unsignedYear = year + 280804; + return (unsignedYear << 11) | (month << 7) | (day << 2) | flags; + } + static generateCalendarToISOKey({ year, month, day }, overflow) { + const flags = overflow === 'constrain' ? 0b10 : 0b11; + return this.privKey(year, month, day, flags); + } + static generateISOToCalendarKey({ year, month, day }) { + return this.privKey(year, month, day, 1); + } + static generateMonthListKey(year) { + return this.privKey(year, 0, 0, 0); + } } OneObjectCache.objectMap = new WeakMapCtor(); OneObjectCache.MAX_CACHE_ENTRIES = 1000; /** * Returns a WeakMap-backed cache that's used to store expensive results - * that are associated with a particular Temporal object instance. + * that are associated with a particular ISO Date Record object instance. * * @param obj - object to associate with the cache */ -OneObjectCache.getCacheForObject = function (obj) { +OneObjectCache.getCacheForObject = function (id, obj) { let cache = Call(WeakMapPrototypeGet, OneObjectCache.objectMap, [obj]); if (!cache) { - cache = new OneObjectCache(); + cache = new OneObjectCache(id); Call(WeakMapPrototypeSet, OneObjectCache.objectMap, [obj, cache]); } return cache; @@ -569,12 +601,13 @@ const nonIsoHelperBase = { try { return Call(IntlDateTimeFormatPrototypeFormatToParts, dateTimeFormat, [legacyDate]); } catch (e) { - throw new RangeErrorCtor(`Invalid ISO date: ${isoString}`); + if (e instanceof RangeErrorCtor) throw new RangeErrorCtor(`Invalid ISO date: ${isoString}`); + throw e; } }, isoToCalendarDate(isoDate, cache) { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; - const key = JSONStringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); + const key = OneObjectCache.generateISOToCalendarKey(isoDate); const cached = cache.get(key); if (cached) return cached; @@ -668,14 +701,7 @@ const nonIsoHelperBase = { cache.set(key, calendarDate); // Also cache the reverse mapping const cacheReverse = (overflow) => { - const keyReverse = JSONStringify({ - func: 'calendarToIsoDate', - year: calendarDate.year, - month: calendarDate.month, - day: calendarDate.day, - overflow, - id: this.id - }); + const keyReverse = OneObjectCache.generateCalendarToISOKey(calendarDate, overflow); cache.set(keyReverse, isoDate); }; Call(ArrayPrototypeForEach, ['constrain', 'reject'], [cacheReverse]); @@ -704,48 +730,48 @@ const nonIsoHelperBase = { } } }, - /** Fill in missing parts of the (year, era, eraYear) tuple */ - completeEraYear(calendarDate) { - const eraFromYear = (year) => { - let eraYear; - const adjustedCalendarDate = { ...calendarDate, year }; - const ix = Call(ArrayPrototypeFindIndex, this.eras, [ - (e, i) => { - if (i === this.eras.length - 1) { - if (e.skip) { - // This last era is only present for legacy ICU data. Treat the - // previous era as the last era. - e = this.eras[i - 1]; - } - if (e.reverseOf) { - // This is a reverse-sign era (like BCE) which must be the oldest - // era. Count years backwards. - if (year > 0) throw new RangeErrorCtor(`Signed year ${year} is invalid for era ${e.name}`); - eraYear = e.anchorEpoch.year - year; - return true; - } - // last era always gets all "leftover" (older than epoch) years, - // so no need for a comparison like below. - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); - return true; + /** Private helper function */ + eraFromYear(calendarDate) { + const { year } = calendarDate; + let eraYear; + const ix = Call(ArrayPrototypeFindIndex, this.eras, [ + (e, i) => { + if (i === this.eras.length - 1) { + if (e.skip) { + // This last era is only present for legacy ICU data. Treat the + // previous era as the last era. + e = this.eras[i - 1]; } - const comparison = nonIsoHelperBase.compareCalendarDates(adjustedCalendarDate, e.anchorEpoch); - if (comparison >= 0) { - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + if (e.reverseOf) { + // This is a reverse-sign era (like BCE) which must be the oldest + // era. Count years backwards. + if (year > 0) throw new RangeErrorCtor(`Signed year ${year} is invalid for era ${e.name}`); + eraYear = e.anchorEpoch.year - year; return true; } - return false; + // last era always gets all "leftover" (older than epoch) years, + // so no need for a comparison like below. + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; } - ]); - if (ix === -1) throw new RangeErrorCtor(`Year ${year} was not matched by any era`); - let matchingEra = this.eras[ix]; - if (matchingEra.skip) matchingEra = this.eras[ix - 1]; - return { eraYear, era: matchingEra.code }; - }; - + const comparison = nonIsoHelperBase.compareCalendarDates(calendarDate, e.anchorEpoch); + if (comparison >= 0) { + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; + } + return false; + } + ]); + if (ix === -1) throw new RangeErrorCtor(`Year ${year} was not matched by any era`); + let matchingEra = this.eras[ix]; + if (matchingEra.skip) matchingEra = this.eras[ix - 1]; + return { eraYear, era: matchingEra.code }; + }, + /** Fill in missing parts of the (year, era, eraYear) tuple */ + completeEraYear(calendarDate) { let { year, eraYear, era } = calendarDate; if (year !== undefined) { - const matchData = eraFromYear(year); + const matchData = this.eraFromYear(calendarDate); ({ eraYear, era } = matchData); if (calendarDate.era !== undefined && CanonicalizeEraInCalendar(this.id, calendarDate.era) !== era) { throw new RangeErrorCtor(`Input era ${calendarDate.era} doesn't match calculated value ${era}`); @@ -769,7 +795,8 @@ const nonIsoHelperBase = { // the era or after its end as long as it's in the same year. If that // happens, we'll adjust the era/eraYear pair to be the correct era for // the `year`. - ({ eraYear, era } = eraFromYear(year)); + const adjustedCalendarDate = { year, month: calendarDate.month, day: calendarDate.day }; + ({ eraYear, era } = this.eraFromYear(adjustedCalendarDate)); } // validateCalendarDate already ensured that either year or era+eraYear are // present @@ -818,7 +845,7 @@ const nonIsoHelperBase = { date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; - const key = JSONStringify({ func: 'calendarToIsoDate', year, month, day, overflow, id: this.id }); + const key = OneObjectCache.generateCalendarToISOKey(date, overflow); let cached = cache.get(key); if (cached) return cached; // If YMD are present in the input but the input has been constrained @@ -830,14 +857,7 @@ const nonIsoHelperBase = { originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { - keyOriginal = JSONStringify({ - func: 'calendarToIsoDate', - year: originalDate.year, - month: originalDate.month, - day: originalDate.day, - overflow, - id: this.id - }); + keyOriginal = OneObjectCache.generateCalendarToISOKey(originalDate, overflow); cached = cache.get(keyOriginal); if (cached) return cached; } @@ -1215,30 +1235,21 @@ const helperHebrew = makeNonISOHelper([{ code: 'am', isoEpoch: { year: -3760, mo return this.inLeapYear(calendarDate) ? 13 : 12; }, minimumMonthLength(calendarDate) { - return this.minMaxMonthLength(calendarDate, 'min'); + return this.minMaxMonthLength(calendarDate, 0); }, maximumMonthLength(calendarDate) { - return this.minMaxMonthLength(calendarDate, 'max'); + return this.minMaxMonthLength(calendarDate, 1); }, minMaxMonthLength(calendarDate, minOrMax) { const { month, year } = calendarDate; - const monthCode = this.getMonthCode(year, month); - const monthInfo = Call(ArrayPrototypeFind, ObjectEntries(this.months), [(m) => m[1].monthCode === monthCode]); - if (monthInfo === undefined) throw new RangeErrorCtor(`unmatched Hebrew month: ${month}`); - const daysInMonth = monthInfo[1].days; + const monthCode = calendarDate.monthCode ?? this.getMonthCode(year, month); + const daysInMonth = this.monthLengths[monthCode]; + if (daysInMonth === undefined) throw new RangeErrorCtor(`unmatched Hebrew month: ${month}`); return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; }, maxLengthOfMonthCodeInAnyYear(monthCode) { - if ( - monthCode === 'M04' || - monthCode === 'M06' || - monthCode === 'M08' || - monthCode === 'M10' || - monthCode === 'M12' - ) { - return 29; - } - return 30; + const daysInMonth = this.monthLengths[monthCode]; + return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[1]; }, /** Take a guess at what ISO date a particular calendar date corresponds to */ estimateIsoDate(calendarDate) { @@ -1246,20 +1257,36 @@ const helperHebrew = makeNonISOHelper([{ code: 'am', isoEpoch: { year: -3760, mo return { year: year - 3760, month: 1, day: 1 }; }, months: { - Tishri: { leap: 1, regular: 1, monthCode: 'M01', days: 30 }, - Heshvan: { leap: 2, regular: 2, monthCode: 'M02', days: { min: 29, max: 30 } }, - Kislev: { leap: 3, regular: 3, monthCode: 'M03', days: { min: 29, max: 30 } }, - Tevet: { leap: 4, regular: 4, monthCode: 'M04', days: 29 }, - Shevat: { leap: 5, regular: 5, monthCode: 'M05', days: 30 }, - Adar: { leap: undefined, regular: 6, monthCode: 'M06', days: 29 }, - 'Adar I': { leap: 6, regular: undefined, monthCode: 'M05L', days: 30 }, - 'Adar II': { leap: 7, regular: undefined, monthCode: 'M06', days: 29 }, - Nisan: { leap: 8, regular: 7, monthCode: 'M07', days: 30 }, - Iyar: { leap: 9, regular: 8, monthCode: 'M08', days: 29 }, - Sivan: { leap: 10, regular: 9, monthCode: 'M09', days: 30 }, - Tamuz: { leap: 11, regular: 10, monthCode: 'M10', days: 29 }, - Av: { leap: 12, regular: 11, monthCode: 'M11', days: 30 }, - Elul: { leap: 13, regular: 12, monthCode: 'M12', days: 29 } + Tishri: { leap: 1, regular: 1 }, + Heshvan: { leap: 2, regular: 2 }, + Kislev: { leap: 3, regular: 3 }, + Tevet: { leap: 4, regular: 4 }, + Shevat: { leap: 5, regular: 5 }, + Adar: { leap: undefined, regular: 6 }, + 'Adar I': { leap: 6, regular: undefined }, + 'Adar II': { leap: 7, regular: undefined }, + Nisan: { leap: 8, regular: 7 }, + Iyar: { leap: 9, regular: 8 }, + Sivan: { leap: 10, regular: 9 }, + Tamuz: { leap: 11, regular: 10 }, + Av: { leap: 12, regular: 11 }, + Elul: { leap: 13, regular: 12 } + }, + monthLengths: { + // monthCode: len | [min, max] + M01: 30, + M02: [29, 30], + M03: [29, 30], + M04: 29, + M05: 30, + M05L: 30, + M06: 29, + M07: 30, + M08: 29, + M09: 30, + M10: 29, + M11: 30, + M12: 29 }, getMonthCode(year, month) { if (this.inLeapYear({ year })) { @@ -1822,11 +1849,27 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { id: 'chinese', calendarType: 'lunisolar', inLeapYear(calendarDate, cache) { - const months = this.getMonthList(calendarDate.year, cache); - return ObjectEntries(months).length === 13; + return this.getMonthList(calendarDate.year, cache).monthsInYear === 13; }, monthsInYear(calendarDate, cache) { - return this.inLeapYear(calendarDate, cache) ? 13 : 12; + return this.getMonthList(calendarDate.year, cache).monthsInYear; + }, + daysInMonth(calendarDate, cache) { + const { month, year } = calendarDate; + const matchingMonthEntry = this.getMonthList(year, cache)[month]; + if (matchingMonthEntry === undefined) { + throw new RangeErrorCtor(`Invalid month ${month} in Chinese year ${year}`); + } + return matchingMonthEntry.daysInMonth; + }, + daysInPreviousMonth(calendarDate, cache) { + const { month, year } = calendarDate; + + const previousMonthYear = month > 1 ? year : year - 1; + let previousMonthDate = { year: previousMonthYear, month, day: 1 }; + const previousMonth = month > 1 ? month - 1 : this.monthsInYear(previousMonthDate, cache); + + return this.daysInMonth({ year: previousMonthYear, month: previousMonth }, cache); }, minimumMonthLength: (/* calendarDate */) => 29, maximumMonthLength: (/* calendarDate */) => 30, @@ -1882,7 +1925,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { if (calendarYear === undefined) { throw new TypeErrorCtor('Missing year'); } - const key = JSONStringify({ func: 'getMonthList', calendarYear, id: this.id }); + const key = OneObjectCache.generateMonthListKey(calendarYear); const cached = cache.get(key); if (cached) return cached; @@ -1930,24 +1973,30 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { const monthList = {}; let monthIndex = 1; let oldDay; - let oldMonthString; for (;;) { const { day, monthString, relatedYear } = updateCalendarFields(); + if (monthIndex === 1) assert(monthString === '1', `we didn't back up to the beginning of year ${calendarYear}`); + const isLeapMonth = Call(StringPrototypeEndsWith, monthString, ['bis']); + const monthCode = CreateMonthCode( + isLeapMonth ? Call(StringPrototypeSlice, monthString, [0, -3]) : monthString, + isLeapMonth + ); if (oldDay) { - monthList[oldMonthString].daysInMonth = oldDay + 30 - day; + monthList[monthIndex - 1].daysInMonth = oldDay + 30 - day; } oldDay = day; - oldMonthString = monthString; if (relatedYear !== calendarYear) break; - monthList[monthString] = { monthIndex: monthIndex++ }; + monthList[monthIndex] = { monthCode }; + monthList[monthCode] = monthIndex++; + // Move to the next month. Because months are sometimes 29 days, the day of the // calendar month will move forward slowly but not enough to flip over to a new // month before the loop ends at 12-13 months. daysPastJan31 += 30; } - monthList[oldMonthString].daysInMonth = oldDay + 30 - calendarFields.day; + monthList.monthsInYear = monthIndex - 1; // subtract 1, it was incremented after the loop cache.set(key, monthList); return monthList; @@ -1965,11 +2014,11 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { // month. Below we'll normalize the output. if (monthExtra && monthExtra !== 'bis') throw new RangeErrorCtor(`Unexpected leap month suffix: ${monthExtra}`); const monthCode = CreateMonthCode(month, monthExtra !== undefined); - const monthString = `${month}${monthExtra || ''}`; const months = this.getMonthList(year, cache); - const monthInfo = months[monthString]; - if (monthInfo === undefined) throw new RangeErrorCtor(`Unmatched month ${monthString} in Chinese year ${year}`); - month = monthInfo.monthIndex; + month = months[monthCode]; + if (month === undefined) { + throw new RangeErrorCtor(`Unmatched month ${month}${monthExtra || ''} in Chinese year ${year}`); + } return { year, month, day, monthCode }; } else { // When called without input coming from legacy Date output, @@ -1978,25 +2027,20 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { if (month === undefined) { const months = this.getMonthList(year, cache); const { monthNumber, isLeapMonth } = ParseMonthCode(monthCode); - const numberPart = `${monthNumber}${isLeapMonth ? 'bis' : ''}`; - let monthInfo = months[numberPart]; - month = monthInfo && monthInfo.monthIndex; + month = months[monthCode]; // If this leap month isn't present in this year, constrain to the same // day of the previous month. - if (month === undefined && isLeapMonth && monthNumber !== 13 && overflow === 'constrain') { - monthInfo = months[monthNumber]; - if (monthInfo) { - month = monthInfo.monthIndex; - monthCode = CreateMonthCode(monthNumber, false); - } + if (month === undefined && isLeapMonth && overflow === 'constrain') { + const adjustedMonthCode = CreateMonthCode(monthNumber, false); + month = months[adjustedMonthCode]; + monthCode = adjustedMonthCode; } if (month === undefined) { throw new RangeErrorCtor(`Unmatched month ${monthCode} in Chinese year ${year}`); } } else if (monthCode === undefined) { const months = this.getMonthList(year, cache); - const monthEntries = ObjectEntries(months); - const largestMonth = monthEntries.length; + const largestMonth = months.monthsInYear; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day, 1, this.maximumMonthLength()); @@ -2004,22 +2048,16 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength()); } - const matchingMonthEntry = Call(ArrayPrototypeFind, monthEntries, [(entry) => entry[1].monthIndex === month]); - if (matchingMonthEntry === undefined) { + monthCode = months[month].monthCode; + if (monthCode === undefined) { throw new RangeErrorCtor(`Invalid month ${month} in Chinese year ${year}`); } - monthCode = CreateMonthCode( - Call(StringPrototypeReplace, matchingMonthEntry[0], ['bis', '']), - Call(StringPrototypeIndexOf, matchingMonthEntry[0], ['bis']) !== -1 - ); } else { // Both month and monthCode are present. Make sure they don't conflict. const months = this.getMonthList(year, cache); - const { monthNumber, isLeapMonth } = ParseMonthCode(monthCode); - const numberPart = `${monthNumber}${isLeapMonth ? 'bis' : ''}`; - const monthInfo = months[numberPart]; - if (!monthInfo) throw new RangeErrorCtor(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); - if (month !== monthInfo.monthIndex) { + const monthIndex = months[monthCode]; + if (!monthIndex) throw new RangeErrorCtor(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); + if (month !== monthIndex) { throw new RangeErrorCtor( `monthCode ${monthCode} doesn't correspond to month ${month} in Chinese year ${year}` ); @@ -2052,13 +2090,13 @@ const nonIsoGeneralImpl = { } }, dateToISO(fields, overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.id); const result = this.helper.calendarToIsoDate(fields, overflow, cache); cache.setObject(result); return result; }, monthDayToISOReferenceDate(fields, overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.id); const result = this.helper.monthDayFromFields(fields, overflow, cache); // result.year is a reference year where this month/day exists in this calendar cache.setObject(result); @@ -2108,27 +2146,27 @@ const nonIsoGeneralImpl = { return arrayFromSet(result); }, dateAdd(isoDate, { years, months, weeks, days }, overflow) { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.id, isoDate); const calendarDate = this.helper.isoToCalendarDate(isoDate, cache); const added = this.helper.addCalendar(calendarDate, { years, months, weeks, days }, overflow, cache); const isoAdded = this.helper.calendarToIsoDate(added, 'constrain', cache); // The new object's cache starts with the cache of the old object - if (!OneObjectCache.getCacheForObject(isoAdded)) { - const newCache = new OneObjectCache(cache); + if (!OneObjectCache.getCacheForObject(this.id, isoAdded)) { + const newCache = new OneObjectCache(this.id, cache); newCache.setObject(isoAdded); } return isoAdded; }, dateUntil(one, two, largestUnit) { - const cacheOne = OneObjectCache.getCacheForObject(one); - const cacheTwo = OneObjectCache.getCacheForObject(two); + const cacheOne = OneObjectCache.getCacheForObject(this.id, one); + const cacheTwo = OneObjectCache.getCacheForObject(this.id, two); const calendarOne = this.helper.isoToCalendarDate(one, cacheOne); const calendarTwo = this.helper.isoToCalendarDate(two, cacheTwo); const result = this.helper.untilCalendar(calendarOne, calendarTwo, largestUnit, cacheOne); return result; }, isoToDate(isoDate, requestedFields) { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.id, isoDate); const calendarDate = this.helper.isoToCalendarDate(isoDate, cache); if (requestedFields.dayOfWeek) { calendarDate.dayOfWeek = impl['iso8601'].isoToDate(isoDate, { dayOfWeek: true }).dayOfWeek; diff --git a/polyfill/lib/monthcode.mjs b/polyfill/lib/monthcode.mjs index e426593fd..b4db11f8f 100644 --- a/polyfill/lib/monthcode.mjs +++ b/polyfill/lib/monthcode.mjs @@ -2,24 +2,31 @@ import { String as StringCtor, RangeError as RangeErrorCtor, TypeError as TypeErrorCtor, + ArrayFrom, StringPrototypePadStart, - RegExpPrototypeExec + StringPrototypeSlice } from './primordials.mjs'; import Call from 'es-abstract/2024/Call.js'; import ToPrimitive from 'es-abstract/2024/ToPrimitive.js'; -import { monthCode as MONTH_CODE_REGEX } from './regex.mjs'; +const digitsForMonthNumber = ArrayFrom({ length: 100 }, (_, i) => (i < 10 ? `0${i}` : `${i}`)); export function ParseMonthCode(argument) { const value = ToPrimitive(argument, StringCtor); if (typeof value !== 'string') throw new TypeErrorCtor('month code must be a string'); - const match = Call(RegExpPrototypeExec, MONTH_CODE_REGEX, [value]); - if (!match) throw new RangeErrorCtor(`bad month code ${value}; must match M01-M99 or M00L-M99L`); - return { - monthNumber: +(match[1] ?? match[3] ?? match[5]), - isLeapMonth: (match[2] ?? match[4] ?? match[6]) === 'L' - }; + const digits = Call(StringPrototypeSlice, value, [1, 3]); + const monthNumber = digits.length === 2 ? +digits | 0 : -1; // -1 ensures failure + const isLeapMonth = value.length === 4; + if ( + !(monthNumber >= 0) || + digits !== digitsForMonthNumber[monthNumber] || + value[0] !== 'M' || + (isLeapMonth ? value[3] !== 'L' : value.length !== 3 || monthNumber === 0) + ) { + throw new RangeErrorCtor(`bad month code ${value}; must match M01-M99 or M00L-M99L`); + } + return { monthNumber, isLeapMonth }; } export function CreateMonthCode(monthNumber, isLeapMonth) { diff --git a/polyfill/lib/plaindate.mjs b/polyfill/lib/plaindate.mjs index 476d67747..9a7869e0c 100644 --- a/polyfill/lib/plaindate.mjs +++ b/polyfill/lib/plaindate.mjs @@ -120,7 +120,10 @@ export class PlainDate { withCalendar(calendar) { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); calendar = ES.ToTemporalCalendarIdentifier(calendar); - return ES.CreateTemporalDate(GetSlot(this, ISO_DATE), calendar); + // Don't reuse the same ISODate object, as it should start with a fresh + // calendar cache + const { year, month, day } = GetSlot(this, ISO_DATE); + return ES.CreateTemporalDate({ year, month, day }, calendar); } add(temporalDurationLike, options = undefined) { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); @@ -198,14 +201,14 @@ export class PlainDate { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); const calendar = GetSlot(this, CALENDAR); const fields = ES.ISODateToFields(calendar, GetSlot(this, ISO_DATE)); - const isoDate = ES.CalendarYearMonthFromFields(calendar, fields); + const isoDate = ES.CalendarYearMonthFromFields(calendar, fields, 'constrain'); return ES.CreateTemporalYearMonth(isoDate, calendar); } toPlainMonthDay() { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); const calendar = GetSlot(this, CALENDAR); const fields = ES.ISODateToFields(calendar, GetSlot(this, ISO_DATE)); - const isoDate = ES.CalendarMonthDayFromFields(calendar, fields); + const isoDate = ES.CalendarMonthDayFromFields(calendar, fields, 'constrain'); return ES.CreateTemporalMonthDay(isoDate, calendar); } diff --git a/polyfill/lib/plaindatetime.mjs b/polyfill/lib/plaindatetime.mjs index fd29dff1b..793a78531 100644 --- a/polyfill/lib/plaindatetime.mjs +++ b/polyfill/lib/plaindatetime.mjs @@ -182,7 +182,13 @@ export class PlainDateTime { withCalendar(calendar) { if (!ES.IsTemporalDateTime(this)) throw new TypeErrorCtor('invalid receiver'); calendar = ES.ToTemporalCalendarIdentifier(calendar); - return ES.CreateTemporalDateTime(GetSlot(this, ISO_DATE_TIME), calendar); + // Don't reuse the same ISODate object, as it should start with a fresh + // calendar cache + const { + isoDate: { year, month, day }, + time + } = GetSlot(this, ISO_DATE_TIME); + return ES.CreateTemporalDateTime(ES.CombineISODateAndTimeRecord({ year, month, day }, time), calendar); } add(temporalDurationLike, options = undefined) { if (!ES.IsTemporalDateTime(this)) throw new TypeErrorCtor('invalid receiver'); diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index 7f721be28..8e35329aa 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -69,5 +69,3 @@ const fraction = /(\d+)(?:[.,](\d{1,9}))?/; const durationDate = /(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?/; const durationTime = new RegExpCtor(`(?:${fraction.source}H)?(?:${fraction.source}M)?(?:${fraction.source}S)?`); export const duration = new RegExpCtor(`^([+-])?P${durationDate.source}(?:T(?!$)${durationTime.source})?$`, 'i'); - -export const monthCode = /^M(?:(00)(L)|(0[1-9])(L)?|([1-9][0-9])(L)?)$/; diff --git a/polyfill/test/all.mjs b/polyfill/test/all.mjs index d25fd9ae0..8a2c5c2fa 100644 --- a/polyfill/test/all.mjs +++ b/polyfill/test/all.mjs @@ -20,6 +20,8 @@ import './math.mjs'; // Internal 96-bit integer implementation, not suitable for test262 import './timeduration.mjs'; +import './monthcode.mjs'; + Promise.resolve() .then(() => { return Demitasse.report(Pretty.reporter); diff --git a/polyfill/test/monthcode.mjs b/polyfill/test/monthcode.mjs new file mode 100644 index 000000000..968000f8e --- /dev/null +++ b/polyfill/test/monthcode.mjs @@ -0,0 +1,140 @@ +import Demitasse from '@pipobscure/demitasse'; +const { describe, it, report } = Demitasse; + +import Pretty from '@pipobscure/demitasse-pretty'; +const { reporter } = Pretty; + +import { strict as assert } from 'assert'; +const { deepEqual, equal, throws } = assert; + +import { CreateMonthCode, ParseMonthCode } from '../lib/monthcode.mjs'; + +function badMonthCode(code) { + throws(() => ParseMonthCode(code), RangeError, code); +} + +describe('ParseMonthCode', () => { + it('all Gregorian month codes', () => { + ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', 'M10', 'M11', 'M12'].forEach((code, ix) => { + deepEqual(ParseMonthCode(code), { monthNumber: ix + 1, isLeapMonth: false }); + }); + }); + it('Intercalary month 13', () => { + deepEqual(ParseMonthCode('M13'), { monthNumber: 13, isLeapMonth: false }); + }); + it('all Chinese leap month codes', () => { + ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'].forEach( + (code, ix) => { + deepEqual(ParseMonthCode(code), { monthNumber: ix + 1, isLeapMonth: true }); + } + ); + }); + it('M00L (valid but does not occur in currently supported calendars)', () => { + deepEqual(ParseMonthCode('M00L'), { monthNumber: 0, isLeapMonth: true }); + }); + it('various other month codes that do not occur in currently supported calendars', () => { + const tests = [ + ['M14', 14, false], + ['M13L', 13, true], + ['M99', 99, false], + ['M99L', 99, true], + ['M42', 42, false], + ['M57L', 57, true] + ]; + for (const [code, monthNumber, isLeapMonth] of tests) { + deepEqual(ParseMonthCode(code), { monthNumber, isLeapMonth }); + } + }); + it('goes through ToPrimitive', () => { + ['toString', Symbol.toPrimitive].forEach((prop) => { + const convertibleObject = { + [prop]() { + return 'M01'; + } + }; + deepEqual(ParseMonthCode(convertibleObject), { monthNumber: 1, isLeapMonth: false }, prop); + }); + }); + it('no M00', () => badMonthCode('M00')); + it('missing leading zero', () => { + badMonthCode('M1'); + badMonthCode('M5L'); + }); + it('number too big', () => { + badMonthCode('M100'); + badMonthCode('M999L'); + }); + it('negative number', () => { + badMonthCode('M-3'); + badMonthCode('M-7L'); + }); + it('decimal point', () => { + badMonthCode('M2.'); + badMonthCode('M.9L'); + badMonthCode('M0.L'); + }); + it('no leading space', () => { + badMonthCode('M 5'); + badMonthCode('M 9L'); + }); + it('not a number', () => { + badMonthCode('M__'); + badMonthCode('MffL'); + }); + it('wrong leading character', () => { + badMonthCode('m11'); + badMonthCode('N11L'); + }); + it('missing leading character', () => { + badMonthCode('12'); + badMonthCode('03L'); + }); + it('wrong leap signifier', () => { + badMonthCode('M06l'); + badMonthCode('M06T'); + }); + it('junk at end of string', () => badMonthCode('M04L+')); + it('wrong primitive type', () => { + [true, 3, Symbol('M01'), 7n].forEach((wrongType) => { + throws(() => ParseMonthCode(wrongType), TypeError, typeof wrongType); + }); + }); + it('wrong toString', () => { + throws(() => ParseMonthCode({}), RangeError); + }); +}); + +describe('CreateMonthCode', () => { + it('all Gregorian month codes', () => { + ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', 'M10', 'M11', 'M12'].forEach((code, ix) => { + equal(CreateMonthCode(ix + 1, false), code); + }); + }); + it('Intercalary month 13', () => equal(CreateMonthCode(13, false), 'M13')); + it('all Chinese leap month codes', () => { + ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'].forEach( + (code, ix) => { + equal(CreateMonthCode(ix + 1, true), code); + } + ); + }); + it('M00L (valid but does not occur in currently supported calendars)', () => equal(CreateMonthCode(0, true), 'M00L')); + it('various other month codes that do not occur in currently supported calendars', () => { + const tests = [ + [14, false, 'M14'], + [13, true, 'M13L'], + [99, false, 'M99'], + [99, true, 'M99L'], + [42, false, 'M42'], + [57, true, 'M57L'] + ]; + for (const [monthNumber, isLeapMonth, code] of tests) { + equal(CreateMonthCode(monthNumber, isLeapMonth), code); + } + }); +}); + +import { normalize } from 'path'; +if (normalize(import.meta.url.slice(8)) === normalize(process.argv[1])) { + report(reporter).then((failed) => process.exit(failed ? 1 : 0)); +}