diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 3d9ff73d..ba0bf001 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -170,7 +170,8 @@ function abs(x: JSBI): JSBI { return x; } -const BUILTIN_CASTS = new Map string | number>([ +type BuiltinCastFunction = (v: unknown) => string | number; +const BUILTIN_CASTS = new Map([ ['year', ToIntegerThrowOnInfinity], ['month', ToPositiveInteger], ['monthCode', ToString], @@ -306,7 +307,7 @@ function ParseTemporalTimeZone(stringIdent: string) { let { ianaName, offset, z } = ParseTemporalTimeZoneString(stringIdent); if (ianaName) return ianaName; if (z) return 'UTC'; - return offset; + return offset; // if !ianaName && !z then offset must be present } function FormatCalendarAnnotation(id: string, showCalendar: Temporal.ShowCalendarOption['calendarName']) { @@ -315,9 +316,9 @@ function FormatCalendarAnnotation(id: string, showCalendar: Temporal.ShowCalenda return `[u-ca=${id}]`; } -function ParseISODateTime(isoString: string, { zoneRequired }: { zoneRequired: boolean }) { - const regex = zoneRequired ? PARSE.instant : PARSE.datetime; - const match = regex.exec(isoString); +function ParseISODateTime(isoString: string) { + // ZDT is the superset of fields for every other Temporal type + const match = PARSE.zoneddatetime.exec(isoString); if (!match) throw new RangeError(`invalid ISO 8601 string: ${isoString}`); let yearString = match[1]; if (yearString[0] === '\u2212') yearString = `-${yearString.slice(1)}`; @@ -380,19 +381,23 @@ function ParseISODateTime(isoString: string, { zoneRequired }: { zoneRequired: b } function ParseTemporalInstantString(isoString: string) { - return ParseISODateTime(isoString, { zoneRequired: true }); + const result = ParseISODateTime(isoString); + if (!result.z && !result.offset) throw new RangeError('Temporal.Instant requires a time zone offset'); + return result; } function ParseTemporalZonedDateTimeString(isoString: string) { - return ParseISODateTime(isoString, { zoneRequired: true }); + const result = ParseISODateTime(isoString); + if (!result.ianaName) throw new RangeError('Temporal.ZonedDateTime requires a time zone ID in brackets'); + return result; } function ParseTemporalDateTimeString(isoString: string) { - return ParseISODateTime(isoString, { zoneRequired: false }); + return ParseISODateTime(isoString); } function ParseTemporalDateString(isoString: string) { - return ParseISODateTime(isoString, { zoneRequired: false }); + return ParseISODateTime(isoString); } function ParseTemporalTimeString(isoString: string) { @@ -410,9 +415,7 @@ function ParseTemporalTimeString(isoString: string) { calendar = match[15]; } else { let z; - ({ hour, minute, second, millisecond, microsecond, nanosecond, calendar, z } = ParseISODateTime(isoString, { - zoneRequired: false - })); + ({ hour, minute, second, millisecond, microsecond, nanosecond, calendar, z } = ParseISODateTime(isoString)); if (z) throw new RangeError('Z designator not supported for PlainTime'); } return { hour, minute, second, millisecond, microsecond, nanosecond, calendar }; @@ -429,7 +432,7 @@ function ParseTemporalYearMonthString(isoString: string) { calendar = match[3]; } else { let z; - ({ year, month, calendar, day: referenceISODay, z } = ParseISODateTime(isoString, { zoneRequired: false })); + ({ year, month, calendar, day: referenceISODay, z } = ParseISODateTime(isoString)); if (z) throw new RangeError('Z designator not supported for PlainYearMonth'); } return { year, month, calendar, referenceISODay }; @@ -443,7 +446,7 @@ function ParseTemporalMonthDayString(isoString: string) { day = ToInteger(match[2]); } else { let z; - ({ month, day, calendar, year: referenceISOYear, z } = ParseISODateTime(isoString, { zoneRequired: false })); + ({ month, day, calendar, year: referenceISOYear, z } = ParseISODateTime(isoString)); if (z) throw new RangeError('Z designator not supported for PlainMonthDay'); } return { month, day, calendar, referenceISOYear }; @@ -458,7 +461,7 @@ function ParseTemporalTimeZoneString(stringIdent: string): Partial<{ let canonicalIdent = GetCanonicalTimeZoneIdentifier(stringIdent); if (canonicalIdent) { canonicalIdent = canonicalIdent.toString(); - if (ParseOffsetString(canonicalIdent) !== null) return { offset: canonicalIdent }; + if (TestTimeZoneOffsetString(canonicalIdent)) return { offset: canonicalIdent }; return { ianaName: canonicalIdent }; } } catch { @@ -466,10 +469,14 @@ function ParseTemporalTimeZoneString(stringIdent: string): Partial<{ } try { // Try parsing ISO string instead - return ParseISODateTime(stringIdent, { zoneRequired: true }); + const result = ParseISODateTime(stringIdent); + if (result.z || result.offset || result.ianaName) { + return result; + } } catch { - throw new RangeError(`Invalid time zone: ${stringIdent}`); + // fall through } + throw new RangeError(`Invalid time zone: ${stringIdent}`); } function ParseTemporalDurationString(isoString: string) { @@ -514,8 +521,7 @@ function ParseTemporalInstant(isoString: string) { const epochNs = GetEpochFromISOParts(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond); if (epochNs === null) throw new RangeError('DateTime outside of supported range'); - if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset'); - const offsetNs = z ? 0 : ParseOffsetString(offset); + const offsetNs = z ? 0 : ParseTimeZoneOffsetString(offset); return JSBI.subtract(epochNs, JSBI.BigInt(offsetNs)); } @@ -982,7 +988,7 @@ export function ToRelativeTemporalObject(options: { } else { let ianaName, z; ({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar, ianaName, offset, z } = - ParseISODateTime(ToString(relativeTo), { zoneRequired: false })); + ParseISODateTime(ToString(relativeTo))); if (ianaName) timeZone = ianaName; if (z) { offsetBehaviour = 'exact'; @@ -996,7 +1002,7 @@ export function ToRelativeTemporalObject(options: { if (timeZone) { timeZone = ToTemporalTimeZone(timeZone); let offsetNs = 0; - if (offsetBehaviour === 'option') offsetNs = ParseOffsetString(ToString(offset)); + if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(ToString(offset)); const epochNanoseconds = InterpretISODateTimeOffset( year, month, @@ -1063,35 +1069,38 @@ export function LargerOfTwoTemporalUnits( - bag: B, - fields: readonly (keyof B)[], - callerCast?: (value: unknown) => unknown -) { - if (!IsObject(bag)) return false; - let any: B; +export function ToPartialRecord(bagParam: B, fieldsParam: ReadonlyArray) { + // External callers are limited to specific types, but this function's + // implementation uses generic property types. The casts below (and at the + // end) convert to/from generic records. + const bag = bagParam as Record; + const fields = fieldsParam as ReadonlyArray; + let any = false; + let result = {} as typeof bag; for (const property of fields) { const value = bag[property]; if (value !== undefined) { - any = any || ({} as B); - if (callerCast === undefined && BUILTIN_CASTS.has(property as PrimitivePropertyNames)) { - any[property] = BUILTIN_CASTS.get(property as PrimitivePropertyNames)(value) as unknown as B[keyof B]; - } else if (callerCast !== undefined) { - any[property] = callerCast(value) as unknown as B[keyof B]; + any = true; + if (BUILTIN_CASTS.has(property)) { + result[property] = (BUILTIN_CASTS.get(property) as BuiltinCastFunction)(value); } else { - any[property] = value; + result[property] = value; } } } - return any ? any : false; + return any ? (result as B) : false; } export function PrepareTemporalFields( - bag: B, - fields: ReadonlyArray> -): Required | undefined { - if (!IsObject(bag)) return undefined; - const result = {} as B; + bagParam: B, + fieldsParam: ReadonlyArray> +): Required { + // External callers are limited to specific types, but this function's + // implementation uses generic property types. The casts below (and at the + // end) convert to/from generic records. + const bag = bagParam as Record; + const fields = fieldsParam as ReadonlyArray>; + const result = {} as typeof bag; let any = false; for (const fieldRecord of fields) { const [property, defaultValue] = fieldRecord; @@ -1100,15 +1109,11 @@ export function PrepareTemporalFields( if (fieldRecord.length === 1) { throw new TypeError(`required property '${property}' missing or undefined`); } - // TODO: two TS issues here: - // 1. `undefined` was stripped from the type of `defaultValue`. Will it - // come back when strictNullChecks is enabled? - // 2. Can types be improved to remove the need for the type assertion? - value = defaultValue as unknown as typeof bag[typeof property]; + value = defaultValue; } else { any = true; if (BUILTIN_CASTS.has(property as PrimitivePropertyNames)) { - value = BUILTIN_CASTS.get(property as PrimitivePropertyNames)(value) as unknown as typeof bag[keyof B]; + value = (BUILTIN_CASTS.get(property) as BuiltinCastFunction)(value); } } result[property] = value; @@ -1117,12 +1122,12 @@ export function PrepareTemporalFields( throw new TypeError('no supported properties found'); } if ( - ((result as Temporal.PlainDateLike)['era'] === undefined) !== - ((result as Temporal.PlainDateLike)['eraYear'] === undefined) + ((result as { era: unknown })['era'] === undefined) !== + ((result as { eraYear: unknown })['eraYear'] === undefined) ) { throw new RangeError("properties 'era' and 'eraYear' must be provided together"); } - return result as Required; + return result as unknown as Required; } // field access in the following operations is intentionally alphabetical @@ -1663,7 +1668,7 @@ export function ToTemporalZonedDateTime( matchMinute = true; // ISO strings may specify offset with less precision } let offsetNs = 0; - if (offsetBehaviour === 'option') offsetNs = ParseOffsetString(offset); + if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(offset); const disambiguation = ToTemporalDisambiguation(options); const offsetOpt = ToTemporalOffset(options, 'reject'); const epochNanoseconds = InterpretISODateTimeOffset( @@ -2083,7 +2088,7 @@ export function ToTemporalCalendar(calendarLikeParam: CalendarParams['from'][0]) if (IsBuiltinCalendar(identifier)) return new TemporalCalendar(identifier); let calendar; try { - ({ calendar } = ParseISODateTime(identifier, { zoneRequired: false })); + ({ calendar } = ParseISODateTime(identifier)); } catch { throw new RangeError(`Invalid calendar: ${identifier}`); } @@ -2682,9 +2687,15 @@ export function TemporalZonedDateTimeToString( return result; } -export function ParseOffsetString(string: string): number { +export function TestTimeZoneOffsetString(string: string) { + return OFFSET.test(StringCtor(string)); +} + +export function ParseTimeZoneOffsetString(string: string): number { const match = OFFSET.exec(StringCtor(string)); - if (!match) return null; + if (!match) { + throw new RangeError(`invalid time zone offset: ${string}`); + } const sign = match[1] === '-' || match[1] === '\u2212' ? -1 : +1; const hours = +match[2]; const minutes = +(match[3] || 0); @@ -2694,8 +2705,10 @@ export function ParseOffsetString(string: string): number { } export function GetCanonicalTimeZoneIdentifier(timeZoneIdentifier: string): string { - const offsetNs = ParseOffsetString(timeZoneIdentifier); - if (offsetNs !== null) return FormatTimeZoneOffsetString(offsetNs); + if (TestTimeZoneOffsetString(timeZoneIdentifier)) { + const offsetNs = ParseTimeZoneOffsetString(timeZoneIdentifier); + return FormatTimeZoneOffsetString(offsetNs); + } const formatter = getIntlDateTimeFormatEnUsForTimeZone(StringCtor(timeZoneIdentifier)); return formatter.resolvedOptions().timeZone; } diff --git a/lib/regex.ts b/lib/regex.ts index a4382f16..763a37d9 100644 --- a/lib/regex.ts +++ b/lib/regex.ts @@ -18,14 +18,10 @@ export const offset = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])(?::?([0-5 const zonesplit = new RegExp(`(?:([zZ])|(?:${offset.source})?)(?:\\[(${timeZoneID.source})\\])?`); const calendar = new RegExp(`\\[u-ca=(${calendarID.source})\\]`); -export const instant = new RegExp( +export const zoneddatetime = new RegExp( `^${datesplit.source}(?:(?:T|\\s+)${timesplit.source})?${zonesplit.source}(?:${calendar.source})?$`, 'i' ); -export const datetime = new RegExp( - `^${datesplit.source}(?:(?:T|\\s+)${timesplit.source})?(?:${zonesplit.source})?(?:${calendar.source})?$`, - 'i' -); export const time = new RegExp(`^${timesplit.source}(?:${zonesplit.source})?(?:${calendar.source})?$`, 'i'); diff --git a/lib/timezone.ts b/lib/timezone.ts index b6d6ac0c..9da3c8b3 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -50,9 +50,9 @@ export class TimeZone implements Temporal.TimeZone { const instant = ES.ToTemporalInstant(instantParam); const id = GetSlot(this, TIMEZONE_ID); - const offsetNs = ES.ParseOffsetString(id); - if (offsetNs !== null) return offsetNs; - + if (ES.TestTimeZoneOffsetString(id)) { + return ES.ParseTimeZoneOffsetString(id); + } return ES.GetIANATimeZoneOffsetNanoseconds(GetSlot(instant, EPOCHNANOSECONDS), id); } getOffsetStringFor(instantParam: Params['getOffsetStringFor'][0]): Return['getOffsetStringFor'] { @@ -84,8 +84,7 @@ export class TimeZone implements Temporal.TimeZone { const Instant = GetIntrinsic('%Temporal.Instant%'); const id = GetSlot(this, TIMEZONE_ID); - const offsetNs = ES.ParseOffsetString(id); - if (offsetNs !== null) { + if (ES.TestTimeZoneOffsetString(id)) { const epochNs = ES.GetEpochFromISOParts( GetSlot(dateTime, ISO_YEAR), GetSlot(dateTime, ISO_MONTH), @@ -98,6 +97,7 @@ export class TimeZone implements Temporal.TimeZone { GetSlot(dateTime, ISO_NANOSECOND) ); if (epochNs === null) throw new RangeError('DateTime outside of supported range'); + const offsetNs = ES.ParseTimeZoneOffsetString(id); return [new Instant(JSBI.subtract(epochNs, JSBI.BigInt(offsetNs)))]; } @@ -121,7 +121,7 @@ export class TimeZone implements Temporal.TimeZone { const id = GetSlot(this, TIMEZONE_ID); // Offset time zones or UTC have no transitions - if (ES.ParseOffsetString(id) !== null || id === 'UTC') { + if (ES.TestTimeZoneOffsetString(id) || id === 'UTC') { return null; } @@ -136,7 +136,7 @@ export class TimeZone implements Temporal.TimeZone { const id = GetSlot(this, TIMEZONE_ID); // Offset time zones or UTC have no transitions - if (ES.ParseOffsetString(id) !== null || id === 'UTC') { + if (ES.TestTimeZoneOffsetString(id) || id === 'UTC') { return null; } diff --git a/lib/zoneddatetime.ts b/lib/zoneddatetime.ts index 1c535f6c..56c1af03 100644 --- a/lib/zoneddatetime.ts +++ b/lib/zoneddatetime.ts @@ -230,7 +230,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { fields = ES.PrepareTemporalFields(fields, entries as any); const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = ES.InterpretTemporalDateTimeFields(calendar, fields, options); - const offsetNs = ES.ParseOffsetString(fields.offset); + const offsetNs = ES.ParseTimeZoneOffsetString(fields.offset); const epochNanoseconds = ES.InterpretISODateTimeOffset( year, month, diff --git a/test/duration.mjs b/test/duration.mjs index d8062936..a2e4734f 100644 --- a/test/duration.mjs +++ b/test/duration.mjs @@ -687,6 +687,15 @@ describe('Duration', () => { throws(() => oneDay.add(hours24, { relativeTo: { year: 2019, month: 11 } }), TypeError); throws(() => oneDay.add(hours24, { relativeTo: { year: 2019, day: 3 } }), TypeError); }); + it('throws with invalid offset in relativeTo', () => { + throws( + () => + Temporal.Duration.from('P2D').add('P1M', { + relativeTo: { year: 2021, month: 11, day: 26, offset: '+088:00', timeZone: 'Europe/London' } + }), + RangeError + ); + }); }); describe('Duration.subtract()', () => { const duration = Duration.from({ days: 3, hours: 1, minutes: 10 }); @@ -930,6 +939,15 @@ describe('Duration', () => { equal(zero2.microseconds, 0); equal(zero2.nanoseconds, 0); }); + it('throws with invalid offset in relativeTo', () => { + throws( + () => + Temporal.Duration.from('P2D').subtract('P1M', { + relativeTo: { year: 2021, month: 11, day: 26, offset: '+088:00', timeZone: 'Europe/London' } + }), + RangeError + ); + }); }); describe('Duration.abs()', () => { it('makes a copy of a positive duration', () => { @@ -1514,6 +1532,16 @@ describe('Duration', () => { equal(`${yearAndHalf.round({ relativeTo: '2020-01-01', smallestUnit: 'years' })}`, 'P1Y'); equal(`${yearAndHalf.round({ relativeTo: '2020-07-01', smallestUnit: 'years' })}`, 'P2Y'); }); + it('throws with invalid offset in relativeTo', () => { + throws( + () => + Temporal.Duration.from('P1M280D').round({ + smallestUnit: 'month', + relativeTo: { year: 2021, month: 11, day: 26, offset: '+088:00', timeZone: 'Europe/London' } + }), + RangeError + ); + }); }); describe('Duration.total()', () => { const d = new Duration(5, 5, 5, 5, 5, 5, 5, 5, 5, 5); @@ -1853,6 +1881,16 @@ describe('Duration', () => { equal(d.total({ unit: 'microsecond', relativeTo }), d.total({ unit: 'microseconds', relativeTo })); equal(d.total({ unit: 'nanosecond', relativeTo }), d.total({ unit: 'nanoseconds', relativeTo })); }); + it('throws with invalid offset in relativeTo', () => { + throws( + () => + Temporal.Duration.from('P1M280D').total({ + unit: 'month', + relativeTo: { year: 2021, month: 11, day: 26, offset: '+088:00', timeZone: 'Europe/London' } + }), + RangeError + ); + }); }); describe('Duration.compare', () => { describe('time units only', () => { @@ -1949,6 +1987,15 @@ describe('Duration', () => { it('does not lose precision when totaling everything down to nanoseconds', () => { notEqual(Duration.compare({ days: 200 }, { days: 200, nanoseconds: 1 }), 0); }); + it('throws with invalid offset in relativeTo', () => { + throws(() => { + const d1 = Temporal.Duration.from('P1M280D'); + const d2 = Temporal.Duration.from('P1M281D'); + Temporal.Duration.compare(d1, d2, { + relativeTo: { year: 2021, month: 11, day: 26, offset: '+088:00', timeZone: 'Europe/London' } + }); + }, RangeError); + }); }); }); diff --git a/test/zoneddatetime.mjs b/test/zoneddatetime.mjs index 83223990..b8defd0a 100644 --- a/test/zoneddatetime.mjs +++ b/test/zoneddatetime.mjs @@ -589,6 +589,23 @@ describe('ZonedDateTime', () => { }); equal(`${zdt}`, '1976-11-18T00:00:00-10:30[-10:30]'); }); + it('throws with invalid offset', () => { + const offsets = ['use', 'prefer', 'ignore', 'reject']; + offsets.forEach((offset) => { + throws(() => { + Temporal.ZonedDateTime.from( + { + year: 2021, + month: 11, + day: 26, + offset: '+099:00', + timeZone: 'Europe/London' + }, + { offset } + ); + }, RangeError); + }); + }); describe('Overflow option', () => { const bad = { year: 2019, month: 1, day: 32, timeZone: lagos }; it('reject', () => throws(() => ZonedDateTime.from(bad, { overflow: 'reject' }), RangeError)); @@ -1025,6 +1042,14 @@ describe('ZonedDateTime', () => { throws(() => zdt.with('12:00'), TypeError); throws(() => zdt.with('invalid'), TypeError); }); + it('throws with invalid offset', () => { + const offsets = ['use', 'prefer', 'ignore', 'reject']; + offsets.forEach((offset) => { + throws(() => { + Temporal.ZonedDateTime.from('2022-11-26[Europe/London]').with({ offset: '+088:00' }, { offset }); + }, RangeError); + }); + }); }); describe('.withPlainTime manipulation', () => { @@ -1617,6 +1642,18 @@ describe('ZonedDateTime', () => { equal(`${dt1.until(dt2, { smallestUnit: 'years', roundingMode: 'halfExpand' })}`, 'P2Y'); equal(`${dt2.until(dt1, { smallestUnit: 'years', roundingMode: 'halfExpand' })}`, '-P1Y'); }); + it('throws with invalid offset', () => { + throws(() => { + const zdt = ZonedDateTime.from('2019-01-01T00:00+00:00[UTC]'); + zdt.until({ + year: 2021, + month: 11, + day: 26, + offset: '+099:00', + timeZone: 'Europe/London' + }); + }, RangeError); + }); }); describe('ZonedDateTime.since()', () => { const zdt = ZonedDateTime.from('1976-11-18T15:23:30.123456789+01:00[Europe/Vienna]'); @@ -1948,6 +1985,18 @@ describe('ZonedDateTime', () => { equal(`${dt2.since(dt1, { smallestUnit: 'years', roundingMode: 'halfExpand' })}`, 'P1Y'); equal(`${dt1.since(dt2, { smallestUnit: 'years', roundingMode: 'halfExpand' })}`, '-P2Y'); }); + it('throws with invalid offset', () => { + throws(() => { + const zdt = ZonedDateTime.from('2019-01-01T00:00+00:00[UTC]'); + zdt.since({ + year: 2021, + month: 11, + day: 26, + offset: '+099:00', + timeZone: 'Europe/London' + }); + }, RangeError); + }); }); describe('ZonedDateTime.round()', () => { @@ -2188,6 +2237,17 @@ describe('ZonedDateTime', () => { TypeError ); }); + it('throws with invalid offset', () => { + throws(() => { + zdt.equals({ + year: 2021, + month: 11, + day: 26, + offset: '+099:00', + timeZone: 'Europe/London' + }); + }, RangeError); + }); }); describe('ZonedDateTime.toString()', () => { const zdt1 = ZonedDateTime.from('1976-11-18T15:23+01:00[Europe/Vienna]'); @@ -2895,6 +2955,17 @@ describe('ZonedDateTime', () => { equal(ZonedDateTime.compare(clockBefore, clockAfter), 1); equal(Temporal.PlainDateTime.compare(clockBefore.toPlainDateTime(), clockAfter.toPlainDateTime()), -1); }); + it('throws with invalid offset', () => { + throws(() => { + Temporal.ZonedDateTime.compare(zdt1, { + year: 2021, + month: 11, + day: 26, + offset: '+099:00', + timeZone: 'Europe/London' + }); + }, RangeError); + }); }); });