From 70bd98989d79da847c479b1a3ff05a6a4dc045b2 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Thu, 19 Aug 2021 17:45:49 -0700 Subject: [PATCH] Normative: Allow ISO strings with "Z" + a bracketed IANA name Previously we had decided to always throw on these strings. Based on feedback from the IETF SEDATE working group, we are going to allow them. This means changes to what kinds of strings are allowed in several contexts. See #1695 for more information. The following diagram may be helpful when reading this commit. "datetime" = e.g. "2021-08-19T17:30" "offset" = e.g. "-07:00" "bracket" = e.g. "[America/Vancouver]", "[-07:00]" or "[UTC]" TimeZone.from: - datetime + Z -> UTC - datetime + offset -> offset time zone - datetime + bracket -> IANA time zone - datetime + Z + bracket -> " - datetime + offset + bracket -> " - throws on a bare datetime string ZonedDateTime.from: - datetime + bracket -> preserve wall time in the IANA time zone - datetime + Z + bracket -> preserve exact time in the IANA time zone - datetime + offset + bracket -> consult offset option if ambiguous - throws on a string with no bracket Instant.from: - datetime + Z -> preserve exact time - datetime + offset -> " - datetime + Z + bracket -> preserve exact time, ignore bracket - datetime + offset + bracket -> " - throws on a bare datetime string, or datetime + bracket relativeTo: - datetime + bracket -> do what ZonedDateTime.from does - datetime + Z + bracket -> " - datetime + offset + bracket -> " - anything else -> do what PlainDateTime.from does Closes: #1695 Closes: #1696 --- lib/ecmascript.ts | 63 ++++++++++++++++++++++++++++-------------- lib/zoneddatetime.ts | 2 ++ test/validStrings.mjs | 34 ++++++++--------------- test/zoneddatetime.mjs | 5 ++-- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index bc01cffa..a230730f 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -210,9 +210,11 @@ export const ES = ObjectAssign({}, ES2020, { IsTemporalZonedDateTime: (item) => HasSlot(item, EPOCHNANOSECONDS, TIME_ZONE, CALENDAR), TemporalTimeZoneFromString: (stringIdent) => { let { ianaName, offset, z } = ES.ParseTemporalTimeZoneString(stringIdent); - if (z) ianaName = 'UTC'; - const result = ES.GetCanonicalTimeZoneIdentifier(ianaName || offset); - if (offset && ianaName && ianaName !== offset) { + let identifier = ianaName; + if (!identifier && z) identifier = 'UTC'; + if (!identifier) identifier = offset; + const result = ES.GetCanonicalTimeZoneIdentifier(identifier); + if (offset && identifier !== offset) { const ns = ES.ParseTemporalInstant(stringIdent); const offsetNs = ES.GetIANATimeZoneOffsetNanoseconds(ns, result); if (ES.FormatTimeZoneOffsetString(offsetNs) !== offset) { @@ -243,10 +245,11 @@ export const ES = ObjectAssign({}, ES2020, { const millisecond = ES.ToInteger(fraction.slice(0, 3)); const microsecond = ES.ToInteger(fraction.slice(3, 6)); const nanosecond = ES.ToInteger(fraction.slice(6, 9)); - let offset, z; + let offset; + let z = false; if (match[13]) { - offset = '+00:00'; - z = 'Z'; + offset = undefined; + z = true; } else if (match[14] && match[15]) { const offsetSign = match[14] === '-' || match[14] === '\u2212' ? '-' : '+'; const offsetHours = match[15] || '00'; @@ -399,7 +402,7 @@ export const ES = ObjectAssign({}, ES2020, { return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds }; }, ParseTemporalInstant: (isoString) => { - const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset } = + const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset, z } = ES.ParseTemporalInstantString(isoString); const epochNs = ES.GetEpochFromISOParts( @@ -414,8 +417,8 @@ export const ES = ObjectAssign({}, ES2020, { nanosecond ); if (epochNs === null) throw new RangeError('DateTime outside of supported range'); - if (!offset) throw new RangeError('Temporal.Instant requires a time zone offset'); - const offsetNs = ES.ParseOffsetString(offset); + if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset'); + const offsetNs = z ? 0 : ES.ParseOffsetString(offset); return epochNs.subtract(offsetNs); }, RegulateISODateTime: (year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, overflow) => { @@ -728,6 +731,7 @@ export const ES = ObjectAssign({}, ES2020, { const relativeTo = options.relativeTo; if (relativeTo === undefined) return relativeTo; + let offsetBehaviour = 'option'; let year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar, timeZone, offset; if (ES.Type(relativeTo) === 'Object') { if (ES.IsTemporalZonedDateTime(relativeTo) || ES.IsTemporalDateTime(relativeTo)) return relativeTo; @@ -753,19 +757,25 @@ export const ES = ObjectAssign({}, ES2020, { ({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = ES.InterpretTemporalDateTimeFields(calendar, fields, dateOptions)); offset = relativeTo.offset; + if (offset === undefined) offsetBehaviour = 'wall'; timeZone = relativeTo.timeZone; } else { - let ianaName; - ({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar, ianaName, offset } = + let ianaName, z; + ({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar, ianaName, offset, z } = ES.ParseISODateTime(ES.ToString(relativeTo), { zoneRequired: false })); if (ianaName) timeZone = ianaName; + if (z) { + offsetBehaviour = 'exact'; + } else if (!offset) { + offsetBehaviour = 'wall'; + } if (!calendar) calendar = ES.GetISO8601Calendar(); calendar = ES.ToTemporalCalendar(calendar); } if (timeZone) { timeZone = ES.ToTemporalTimeZone(timeZone); - let offsetNs = null; - if (offset) offsetNs = ES.ParseOffsetString(ES.ToString(offset)); + let offsetNs = 0; + if (offsetBehaviour === 'option') offsetNs = ES.ParseOffsetString(ES.ToString(offset)); const epochNanoseconds = ES.InterpretISODateTimeOffset( year, month, @@ -776,6 +786,7 @@ export const ES = ObjectAssign({}, ES2020, { millisecond, microsecond, nanosecond, + offsetBehaviour, offsetNs, timeZone, 'compatible', @@ -1246,6 +1257,7 @@ export const ES = ObjectAssign({}, ES2020, { millisecond, microsecond, nanosecond, + offsetBehaviour, offsetNs, timeZone, disambiguation, @@ -1254,7 +1266,7 @@ export const ES = ObjectAssign({}, ES2020, { const DateTime = GetIntrinsic('%Temporal.PlainDateTime%'); const dt = new DateTime(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond); - if (offsetNs === null || offsetOpt === 'ignore') { + if (offsetBehaviour === 'wall' || offsetOpt === 'ignore') { // Simple case: ISO string without a TZ offset (or caller wants to ignore // the offset), so just convert DateTime to Instant in the given time zone const instant = ES.BuiltinTimeZoneGetInstantFor(timeZone, dt, disambiguation); @@ -1264,7 +1276,7 @@ export const ES = ObjectAssign({}, ES2020, { // The caller wants the offset to always win ('use') OR the caller is OK // with the offset winning ('prefer' or 'reject') as long as it's valid // for this timezone and date/time. - if (offsetOpt === 'use') { + if (offsetBehaviour === 'exact' || offsetOpt === 'use') { // Calculate the instant for the input's date/time and offset const epochNs = ES.GetEpochFromISOParts( year, @@ -1305,6 +1317,7 @@ export const ES = ObjectAssign({}, ES2020, { }, ToTemporalZonedDateTime: (item, options = ObjectCreate(null)) => { let year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, timeZone, offset, calendar; + let offsetBehaviour = 'option'; if (ES.Type(item) === 'Object') { if (ES.IsTemporalZonedDateTime(item)) return item; calendar = ES.GetTemporalCalendarWithISODefault(item); @@ -1325,20 +1338,29 @@ export const ES = ObjectAssign({}, ES2020, { ES.InterpretTemporalDateTimeFields(calendar, fields, options)); timeZone = ES.ToTemporalTimeZone(fields.timeZone); offset = fields.offset; - if (offset !== undefined) offset = ES.ToString(offset); + if (offset === undefined) { + offsetBehaviour = 'wall'; + } else { + offset = ES.ToString(offset); + } } else { ES.ToTemporalOverflow(options); // validate and ignore - let ianaName; - ({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, ianaName, offset, calendar } = + let ianaName, z; + ({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, ianaName, offset, z, calendar } = ES.ParseTemporalZonedDateTimeString(ES.ToString(item))); if (!ianaName) throw new RangeError('time zone ID required in brackets'); + if (z) { + offsetBehaviour = 'exact'; + } else if (!offset) { + offsetBehaviour = 'wall'; + } const TemporalTimeZone = GetIntrinsic('%Temporal.TimeZone%'); timeZone = new TemporalTimeZone(ianaName); if (!calendar) calendar = ES.GetISO8601Calendar(); calendar = ES.ToTemporalCalendar(calendar); } - let offsetNs = null; - if (offset) offsetNs = ES.ParseOffsetString(offset); + let offsetNs = 0; + if (offsetBehaviour === 'option') offsetNs = ES.ParseOffsetString(offset); const disambiguation = ES.ToTemporalDisambiguation(options); const offsetOpt = ES.ToTemporalOffset(options, 'reject'); const epochNanoseconds = ES.InterpretISODateTimeOffset( @@ -1351,6 +1373,7 @@ export const ES = ObjectAssign({}, ES2020, { millisecond, microsecond, nanosecond, + offsetBehaviour, offsetNs, timeZone, disambiguation, diff --git a/lib/zoneddatetime.ts b/lib/zoneddatetime.ts index 37d992bb..78d06de1 100644 --- a/lib/zoneddatetime.ts +++ b/lib/zoneddatetime.ts @@ -223,6 +223,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { millisecond, microsecond, nanosecond, + 'option', offsetNs, timeZone, disambiguation, @@ -633,6 +634,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { millisecond, microsecond, nanosecond, + 'option', offsetNs, timeZone, 'compatible', diff --git a/test/validStrings.mjs b/test/validStrings.mjs index d1a6e9be..829d7e40 100644 --- a/test/validStrings.mjs +++ b/test/validStrings.mjs @@ -270,18 +270,13 @@ const timeZoneIANAName = withCode( choice(...timezoneNames), (data, result) => (data.ianaName = ES.GetCanonicalTimeZoneIdentifier(result).toString()) ); -const timeZone = withCode( - choice(timeZoneUTCOffset, seq([timeZoneNumericUTCOffset], timeZoneBracketedAnnotation)), - (data) => { - if (!('offset' in data)) data.offset = undefined; - } -); -const timeZoneOffsetRequired = withCode( - choice(utcDesignator, seq(timeZoneNumericUTCOffset, [timeZoneBracketedAnnotation])), - (data) => { - if (!('offset' in data)) data.offset = undefined; - } -); +const timeZoneOffsetRequired = withCode(seq(timeZoneUTCOffset, [timeZoneBracketedAnnotation]), (data) => { + if (!('offset' in data)) data.offset = undefined; +}); +const timeZoneNameRequired = withCode(seq([timeZoneUTCOffset], timeZoneBracketedAnnotation), (data) => { + if (!('offset' in data)) data.offset = undefined; +}); +const timeZone = choice(timeZoneOffsetRequired, timeZoneNameRequired); const temporalTimeZoneIdentifier = withCode(choice(timeZoneNumericUTCOffset, timeZoneIANAName), (data) => { if (!('offset' in data)) data.offset = undefined; }); @@ -291,12 +286,13 @@ const timeSpec = seq( timeHour, choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]])) ); +const timeSpecSeparator = seq(dateTimeSeparator, timeSpec); const dateSpecMonthDay = seq(['--'], dateMonth, ['-'], dateDay); const dateSpecYearMonth = seq(dateYear, ['-'], dateMonth); const date = choice(seq(dateYear, '-', dateMonth, '-', dateDay), seq(dateYear, dateMonth, dateDay)); const time = seq(timeSpec, [timeZone]); -const dateTime = seq(date, [seq(dateTimeSeparator, timeSpec)], [timeZone]); +const dateTime = seq(date, [timeSpecSeparator], [timeZone]); const calendarDateTime = seq(dateTime, [calendar]); const durationFractionalPart = withCode(between(1, 9, digit()), (data, result) => { @@ -348,14 +344,8 @@ const duration = seq( choice(durationDate, durationTime) ); -const instant = seq(date, [seq(dateTimeSeparator, timeSpec)], timeZoneOffsetRequired); -const zonedDateTime = seq( - date, - seq([dateTimeSeparator, timeSpec]), - [timeZoneNumericUTCOffset], - timeZoneBracketedAnnotation, - [calendar] -); +const instant = seq(date, [timeSpecSeparator], timeZoneOffsetRequired); +const zonedDateTime = seq(date, [timeSpecSeparator], timeZoneNameRequired, [calendar]); // goal elements const goals = { @@ -365,7 +355,7 @@ const goals = { Duration: duration, MonthDay: choice(dateSpecMonthDay, dateTime), Time: choice(time, dateTime), - TimeZone: choice(temporalTimeZoneIdentifier, instant), + TimeZone: choice(temporalTimeZoneIdentifier, seq(date, [timeSpecSeparator], timeZone, [calendar])), YearMonth: choice(dateSpecYearMonth, dateTime), ZonedDateTime: zonedDateTime }; diff --git a/test/zoneddatetime.mjs b/test/zoneddatetime.mjs index bdda21ae..e525bf47 100644 --- a/test/zoneddatetime.mjs +++ b/test/zoneddatetime.mjs @@ -307,8 +307,9 @@ describe('ZonedDateTime', () => { throws(() => ZonedDateTime.from('2020-03-08T01:00-08:00'), RangeError); throws(() => ZonedDateTime.from('2020-03-08T01:00Z'), RangeError); }); - it('"Z" is a time zone designation, not an offset', () => { - throws(() => ZonedDateTime.from('2020-03-08T09:00:00Z[America/Los_Angeles]'), RangeError); + it('"Z" means preserve the exact time in the given IANA time zone', () => { + const zdt = ZonedDateTime.from('2020-03-08T09:00:00Z[America/Los_Angeles]'); + equal(zdt.toString(), '2020-03-08T01:00:00-08:00[America/Los_Angeles]'); }); it('ZonedDateTime.from(ISO string leap second) is constrained', () => { equal(