Skip to content

Commit

Permalink
Editorial: refactor time zone offset handling
Browse files Browse the repository at this point in the history
This commit refactors spec text and polyfill code for time zone offsets,
especially to split the handling of offsets in ISO strings from offsets
used as time zone identifiers.

This will help prepare for a later normative commit where time zone
identifiers are limited to minutes precision while ISO string offset
inputs and ZonedDateTime's `offset` property still support nanosecond
precision.
  • Loading branch information
justingrant committed Jun 29, 2023
1 parent 3bf0cfe commit 2ed580c
Show file tree
Hide file tree
Showing 14 changed files with 471 additions and 154 deletions.
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"@tc39/ecma262-biblio": "=2.1.2577",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"ecmarkup": "^17.0.0",
"ecmarkup": "^17.0.1",
"eslint": "^8.42.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
Expand Down
87 changes: 44 additions & 43 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -360,24 +360,6 @@ export function RejectTemporalLikeObject(item) {
}
}

export function CanonicalizeTimeZoneOffsetString(offsetString) {
const offsetNs = ParseTimeZoneOffsetString(offsetString);
return FormatTimeZoneOffsetString(offsetNs);
}

export function ParseTemporalTimeZone(stringIdent) {
const { tzName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
if (tzName) {
if (IsTimeZoneOffsetString(tzName)) return CanonicalizeTimeZoneOffsetString(tzName);
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
return record.primaryIdentifier;
}
if (z) return 'UTC';
// if !tzName && !z then offset must be present
return CanonicalizeTimeZoneOffsetString(offset);
}

export function MaybeFormatCalendarAnnotation(calendar, showCalendar) {
if (showCalendar === 'never') return '';
return FormatCalendarAnnotation(ToTemporalCalendarIdentifier(calendar), showCalendar);
Expand Down Expand Up @@ -570,6 +552,18 @@ export function ParseTemporalMonthDayString(isoString) {
return { month, day, calendar, referenceISOYear };
}

const TIMEZONE_IDENTIFIER = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
const OFFSET_IDENTIFIER = new RegExp(`^${PARSE.offsetIdentifier.source}$`);

export function ParseTimeZoneIdentifier(identifier) {
if (!TIMEZONE_IDENTIFIER.test(identifier)) throw new RangeError(`Invalid time zone identifier: ${identifier}`);
if (OFFSET_IDENTIFIER.test(identifier)) {
const { offsetNanoseconds } = ParseDateTimeUTCOffset(identifier);
return { offsetNanoseconds };
}
return { tzName: identifier };
}

export function ParseTemporalTimeZoneString(stringIdent) {
const bareID = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
if (bareID.test(stringIdent)) return { tzName: stringIdent };
Expand Down Expand Up @@ -641,7 +635,7 @@ export function ParseTemporalInstant(isoString) {
ParseTemporalInstantString(isoString);

if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset');
const offsetNs = z ? 0 : ParseTimeZoneOffsetString(offset);
const offsetNs = z ? 0 : ParseDateTimeUTCOffset(offset).offsetNanoseconds;
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = BalanceISODateTime(
year,
month,
Expand Down Expand Up @@ -1005,7 +999,7 @@ export function ToRelativeTemporalObject(options) {
calendar = ASCIILowercase(calendar);
}
if (timeZone === undefined) return CreateTemporalDate(year, month, day, calendar);
const offsetNs = offsetBehaviour === 'option' ? ParseTimeZoneOffsetString(offset) : 0;
const offsetNs = offsetBehaviour === 'option' ? ParseDateTimeUTCOffset(offset).offsetNanoseconds : 0;
const epochNanoseconds = InterpretISODateTimeOffset(
year,
month,
Expand Down Expand Up @@ -1406,7 +1400,7 @@ export function InterpretISODateTimeOffset(
// the user-provided offset doesn't match any instants for this time
// zone and date/time.
if (offsetOpt === 'reject') {
const offsetStr = FormatTimeZoneOffsetString(offsetNs);
const offsetStr = FormatOffsetTimeZoneIdentifier(offsetNs);
const timeZoneString = IsTemporalTimeZone(timeZone) ? GetSlot(timeZone, TIMEZONE_ID) : 'time zone';
throw new RangeError(`Offset ${offsetStr} is invalid for ${dt} in ${timeZoneString}`);
}
Expand Down Expand Up @@ -1469,7 +1463,7 @@ export function ToTemporalZonedDateTime(item, options) {
ToTemporalOverflow(options); // validate and ignore
}
let offsetNs = 0;
if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(offset);
if (offsetBehaviour === 'option') offsetNs = ParseDateTimeUTCOffset(offset).offsetNanoseconds;
const epochNanoseconds = InterpretISODateTimeOffset(
year,
month,
Expand Down Expand Up @@ -2099,7 +2093,20 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) {
return temporalTimeZoneLike;
}
const identifier = ToString(temporalTimeZoneLike);
return ParseTemporalTimeZone(identifier);
const { tzName, offset, z } = ParseTemporalTimeZoneString(identifier);
if (tzName) {
// tzName is any valid identifier string in brackets, and could be an offset identifier
const { offsetNanoseconds } = ParseTimeZoneIdentifier(tzName);
if (offsetNanoseconds !== undefined) return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);

const record = GetAvailableNamedTimeZoneIdentifier(tzName);
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
return record.primaryIdentifier;
}
if (z) return 'UTC';
// if !tzName && !z then offset must be present
const { offsetNanoseconds } = ParseDateTimeUTCOffset(offset);
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
}

export function ToTemporalTimeZoneIdentifier(slotValue) {
Expand Down Expand Up @@ -2162,7 +2169,7 @@ export function GetOffsetNanosecondsFor(timeZone, instant, getOffsetNanosecondsF

export function GetOffsetStringFor(timeZone, instant) {
const offsetNs = GetOffsetNanosecondsFor(timeZone, instant);
return FormatTimeZoneOffsetString(offsetNs);
return FormatOffsetTimeZoneIdentifier(offsetNs);
}

export function GetPlainDateTimeFor(timeZone, instant, calendar) {
Expand Down Expand Up @@ -2384,7 +2391,7 @@ export function TemporalInstantToString(instant, timeZone, precision) {
let timeZoneString = 'Z';
if (timeZone !== undefined) {
const offsetNs = GetOffsetNanosecondsFor(outputTimeZone, instant);
timeZoneString = FormatISOTimeZoneOffsetString(offsetNs);
timeZoneString = FormatDateTimeUTCOffsetRounded(offsetNs);
}
return `${year}-${month}-${day}T${hour}:${minute}${seconds}${timeZoneString}`;
}
Expand Down Expand Up @@ -2564,7 +2571,7 @@ export function TemporalZonedDateTimeToString(
let result = `${year}-${month}-${day}T${hour}:${minute}${seconds}`;
if (showOffset !== 'never') {
const offsetNs = GetOffsetNanosecondsFor(tz, instant);
result += FormatISOTimeZoneOffsetString(offsetNs);
result += FormatDateTimeUTCOffsetRounded(offsetNs);
}
if (showTimeZone !== 'never') {
const identifier = ToTemporalTimeZoneIdentifier(tz);
Expand All @@ -2575,11 +2582,11 @@ export function TemporalZonedDateTimeToString(
return result;
}

export function IsTimeZoneOffsetString(string) {
export function IsOffsetTimeZoneIdentifier(string) {
return OFFSET.test(string);
}

export function ParseTimeZoneOffsetString(string) {
export function ParseDateTimeUTCOffset(string) {
const match = OFFSET.exec(string);
if (!match) {
throw new RangeError(`invalid time zone offset: ${string}`);
Expand All @@ -2589,7 +2596,9 @@ export function ParseTimeZoneOffsetString(string) {
const minutes = +(match[3] || 0);
const seconds = +(match[4] || 0);
const nanoseconds = +((match[5] || 0) + '000000000').slice(0, 9);
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
const offsetNanoseconds = sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
const hasSubMinutePrecision = match[4] !== undefined || match[5] !== undefined;
return { offsetNanoseconds, hasSubMinutePrecision };
}

let canonicalTimeZoneIdsCache = undefined;
Expand Down Expand Up @@ -2702,17 +2711,16 @@ export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {
return +utc.minus(epochNanoseconds);
}

export function FormatTimeZoneOffsetString(offsetNanoseconds) {
export function FormatOffsetTimeZoneIdentifier(offsetNanoseconds) {
const sign = offsetNanoseconds < 0 ? '-' : '+';
offsetNanoseconds = MathAbs(offsetNanoseconds);
const nanoseconds = offsetNanoseconds % 1e9;
const seconds = MathFloor(offsetNanoseconds / 1e9) % 60;
const minutes = MathFloor(offsetNanoseconds / 60e9) % 60;
const hours = MathFloor(offsetNanoseconds / 3600e9);

const hourString = ISODateTimePartString(hours);
const minutes = MathFloor(offsetNanoseconds / 60e9) % 60;
const minuteString = ISODateTimePartString(minutes);
const seconds = MathFloor(offsetNanoseconds / 1e9) % 60;
const secondString = ISODateTimePartString(seconds);
const nanoseconds = offsetNanoseconds % 1e9;
let post = '';
if (nanoseconds) {
let fraction = `${nanoseconds}`.padStart(9, '0');
Expand All @@ -2724,16 +2732,9 @@ export function FormatTimeZoneOffsetString(offsetNanoseconds) {
return `${sign}${hourString}:${minuteString}${post}`;
}

export function FormatISOTimeZoneOffsetString(offsetNanoseconds) {
export function FormatDateTimeUTCOffsetRounded(offsetNanoseconds) {
offsetNanoseconds = RoundNumberToIncrement(bigInt(offsetNanoseconds), 60e9, 'halfExpand').toJSNumber();
const sign = offsetNanoseconds < 0 ? '-' : '+';
offsetNanoseconds = MathAbs(offsetNanoseconds);
const minutes = (offsetNanoseconds / 60e9) % 60;
const hours = MathFloor(offsetNanoseconds / 3600e9);

const hourString = ISODateTimePartString(hours);
const minuteString = ISODateTimePartString(minutes);
return `${sign}${hourString}:${minuteString}`;
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
}

export function GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond) {
Expand Down
2 changes: 1 addition & 1 deletion polyfill/lib/intl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
this[TZ_ORIGINAL] = ro.timeZone;
} else {
const id = ES.ToString(timeZoneOption);
if (ES.IsTimeZoneOffsetString(id)) {
if (ES.IsOffsetTimeZoneIdentifier(id)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('Intl.DateTimeFormat does not currently support offset time zones');
}
Expand Down
1 change: 1 addition & 0 deletions polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const datesplit = new RegExp(
const timesplit = /(\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?|(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?)?/;
export const offset = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])(?::?([0-5][0-9])(?:[.,](\d{1,9}))?)?)?/;
const offsetpart = new RegExp(`([zZ])|${offset.source}?`);
export const offsetIdentifier = offset;
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;

export const zoneddatetime = new RegExp(
Expand Down
20 changes: 10 additions & 10 deletions polyfill/lib/timezone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export class TimeZone {
throw new RangeError('missing argument: identifier is required');
}
let stringIdentifier = ES.ToString(identifier);
if (ES.IsTimeZoneOffsetString(stringIdentifier)) {
stringIdentifier = ES.CanonicalizeTimeZoneOffsetString(stringIdentifier);
const parseResult = ES.ParseTimeZoneIdentifier(identifier);
if (parseResult.offsetNanoseconds !== undefined) {
stringIdentifier = ES.FormatOffsetTimeZoneIdentifier(parseResult.offsetNanoseconds);
} else {
const record = ES.GetAvailableNamedTimeZoneIdentifier(stringIdentifier);
if (!record) throw new RangeError(`Invalid time zone identifier: ${stringIdentifier}`);
Expand All @@ -55,9 +56,8 @@ export class TimeZone {
instant = ES.ToTemporalInstant(instant);
const id = GetSlot(this, TIMEZONE_ID);

if (ES.IsTimeZoneOffsetString(id)) {
return ES.ParseTimeZoneOffsetString(id);
}
const offsetNanoseconds = ES.ParseTimeZoneIdentifier(id).offsetNanoseconds;
if (offsetNanoseconds !== undefined) return offsetNanoseconds;

return ES.GetNamedTimeZoneOffsetNanoseconds(id, GetSlot(instant, EPOCHNANOSECONDS));
}
Expand Down Expand Up @@ -85,7 +85,8 @@ export class TimeZone {
const Instant = GetIntrinsic('%Temporal.Instant%');
const id = GetSlot(this, TIMEZONE_ID);

if (ES.IsTimeZoneOffsetString(id)) {
const offsetNanoseconds = ES.ParseTimeZoneIdentifier(id).offsetNanoseconds;
if (offsetNanoseconds !== undefined) {
const epochNs = ES.GetUTCEpochNanoseconds(
GetSlot(dateTime, ISO_YEAR),
GetSlot(dateTime, ISO_MONTH),
Expand All @@ -98,8 +99,7 @@ export class TimeZone {
GetSlot(dateTime, ISO_NANOSECOND)
);
if (epochNs === null) throw new RangeError('DateTime outside of supported range');
const offsetNs = ES.ParseTimeZoneOffsetString(id);
return [new Instant(epochNs.minus(offsetNs))];
return [new Instant(epochNs.minus(offsetNanoseconds))];
}

const possibleEpochNs = ES.GetNamedTimeZoneEpochNanoseconds(
Expand All @@ -122,7 +122,7 @@ export class TimeZone {
const id = GetSlot(this, TIMEZONE_ID);

// Offset time zones or UTC have no transitions
if (ES.IsTimeZoneOffsetString(id) || id === 'UTC') {
if (ES.IsOffsetTimeZoneIdentifier(id) || id === 'UTC') {
return null;
}

Expand All @@ -137,7 +137,7 @@ export class TimeZone {
const id = GetSlot(this, TIMEZONE_ID);

// Offset time zones or UTC have no transitions
if (ES.IsTimeZoneOffsetString(id) || id === 'UTC') {
if (ES.IsOffsetTimeZoneIdentifier(id) || id === 'UTC') {
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions polyfill/lib/zoneddatetime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export class ZonedDateTime {

let { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } =
ES.InterpretTemporalDateTimeFields(calendar, fields, options);
const offsetNs = ES.ParseTimeZoneOffsetString(fields.offset);
const offsetNs = ES.ParseDateTimeUTCOffset(fields.offset).offsetNanoseconds;
const timeZone = GetSlot(this, TIME_ZONE);
const epochNanoseconds = ES.InterpretISODateTimeOffset(
year,
Expand Down Expand Up @@ -472,7 +472,7 @@ export class ZonedDateTime {
}

const timeZoneIdentifier = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
if (ES.IsTimeZoneOffsetString(timeZoneIdentifier)) {
if (ES.IsOffsetTimeZoneIdentifier(timeZoneIdentifier)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('toLocaleString does not currently support offset time zones');
} else {
Expand Down
16 changes: 8 additions & 8 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,9 @@ const temporalSign = withCode(
);
const temporalDecimalFraction = fraction;
function saveOffset(data, result) {
data.offset = ES.CanonicalizeTimeZoneOffsetString(result);
data.offset = ES.FormatOffsetTimeZoneIdentifier(ES.ParseDateTimeUTCOffset(result).offsetNanoseconds);
}
const utcOffset = withCode(
const utcOffsetSubMinutePrecision = withCode(
seq(
temporalSign,
hour,
Expand All @@ -261,7 +261,7 @@ const utcOffset = withCode(
),
saveOffset
);
const timeZoneUTCOffset = choice(utcDesignator, utcOffset);
const dateTimeUTCOffset = choice(utcDesignator, utcOffsetSubMinutePrecision);
const timeZoneUTCOffsetName = seq(
sign,
hour,
Expand Down Expand Up @@ -294,7 +294,7 @@ const timeSpec = seq(
timeHour,
choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]]))
);
const timeSpecWithOptionalOffsetNotAmbiguous = withSyntaxConstraints(seq(timeSpec, [timeZoneUTCOffset]), (result) => {
const timeSpecWithOptionalOffsetNotAmbiguous = withSyntaxConstraints(seq(timeSpec, [dateTimeUTCOffset]), (result) => {
if (/^(?:(?!02-?30)(?:0[1-9]|1[012])-?(?:0[1-9]|[12][0-9]|30)|(?:0[13578]|10|12)-?31)$/.test(result)) {
throw new SyntaxError('valid PlainMonthDay');
}
Expand All @@ -312,17 +312,17 @@ const date = withSyntaxConstraints(
choice(seq(dateYear, '-', dateMonth, '-', dateDay), seq(dateYear, dateMonth, dateDay)),
validateDayOfMonth
);
const dateTime = seq(date, [dateTimeSeparator, timeSpec, [timeZoneUTCOffset]]);
const dateTime = seq(date, [dateTimeSeparator, timeSpec, [dateTimeUTCOffset]]);
const annotatedTime = choice(
seq(timeDesignator, timeSpec, [timeZoneUTCOffset], [timeZoneAnnotation], [annotations]),
seq(timeDesignator, timeSpec, [dateTimeUTCOffset], [timeZoneAnnotation], [annotations]),
seq(timeSpecWithOptionalOffsetNotAmbiguous, [timeZoneAnnotation], [annotations])
);
const annotatedDateTime = seq(dateTime, [timeZoneAnnotation], [annotations]);
const annotatedDateTimeTimeRequired = seq(
date,
dateTimeSeparator,
timeSpec,
[timeZoneUTCOffset],
[dateTimeUTCOffset],
[timeZoneAnnotation],
[annotations]
);
Expand Down Expand Up @@ -411,7 +411,7 @@ const duration = seq(
choice(durationDate, durationTime)
);

const instant = seq(date, dateTimeSeparator, timeSpec, timeZoneUTCOffset, [timeZoneAnnotation], [annotations]);
const instant = seq(date, dateTimeSeparator, timeSpec, dateTimeUTCOffset, [timeZoneAnnotation], [annotations]);
const zonedDateTime = seq(dateTime, timeZoneAnnotation, [annotations]);

// goal elements
Expand Down
Loading

0 comments on commit 2ed580c

Please sign in to comment.