Skip to content

Commit

Permalink
Normative: Allow ISO strings with "Z" + a bracketed IANA name
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ptomato authored and Ms2ger committed Sep 9, 2021
1 parent 18c6d33 commit 70bd989
Show file tree
Hide file tree
Showing 4 changed files with 60 additions and 44 deletions.
63 changes: 43 additions & 20 deletions lib/ecmascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -776,6 +786,7 @@ export const ES = ObjectAssign({}, ES2020, {
millisecond,
microsecond,
nanosecond,
offsetBehaviour,
offsetNs,
timeZone,
'compatible',
Expand Down Expand Up @@ -1246,6 +1257,7 @@ export const ES = ObjectAssign({}, ES2020, {
millisecond,
microsecond,
nanosecond,
offsetBehaviour,
offsetNs,
timeZone,
disambiguation,
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -1351,6 +1373,7 @@ export const ES = ObjectAssign({}, ES2020, {
millisecond,
microsecond,
nanosecond,
offsetBehaviour,
offsetNs,
timeZone,
disambiguation,
Expand Down
2 changes: 2 additions & 0 deletions lib/zoneddatetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime {
millisecond,
microsecond,
nanosecond,
'option',
offsetNs,
timeZone,
disambiguation,
Expand Down Expand Up @@ -633,6 +634,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime {
millisecond,
microsecond,
nanosecond,
'option',
offsetNs,
timeZone,
'compatible',
Expand Down
34 changes: 12 additions & 22 deletions test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
};
Expand Down
5 changes: 3 additions & 2 deletions test/zoneddatetime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit 70bd989

Please sign in to comment.