Skip to content

Commit

Permalink
Normative: Limit offset time zones to minutes
Browse files Browse the repository at this point in the history
At implementers' request to reduce the storage requirements of
Temporal.TimeZone from 49+ bits to 12-13 bits, this commit requires
that the [[OffsetNanoseconds]] internal slot of Temporal.TimeZone is
limited to minute precision.

Sub-minute precision is still allowed for custom time zone objects and
built-in named time zones. In other words, this commit changes storage
requirements but not internal calculation requirements.

This commit is fairly narrow:
* Changes |TimeZoneUTCOffsetName| production to restrict allowed
  offset syntax for parsing.
* Changes FormatOffsetTimeZoneIdentifier AO to format minute strings
  only.
* Moves sub-minute offset formatting from FormatOffsetTimeZoneIdentifier
  to instead be inlined in GetOffsetStringFor, which is now the only
  place where sub-minute offsets are formatted.

Fixes tc39#2593.
  • Loading branch information
justingrant committed Jul 18, 2023
1 parent 9253db8 commit bac5a1a
Show file tree
Hide file tree
Showing 6 changed files with 75 additions and 51 deletions.
54 changes: 34 additions & 20 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ 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)) {
// The regex limits the input to minutes precision
const { offsetNanoseconds } = ParseDateTimeUTCOffset(identifier);
return { offsetNanoseconds };
}
Expand Down Expand Up @@ -1430,7 +1431,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 = FormatOffsetTimeZoneIdentifier(offsetNs);
const offsetStr = formatOffsetStringNanoseconds(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 @@ -2135,7 +2136,10 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) {
}
if (z) return 'UTC';
// if !tzName && !z then offset must be present
const { offsetNanoseconds } = ParseDateTimeUTCOffset(offset);
const { offsetNanoseconds, hasSubMinutePrecision } = ParseDateTimeUTCOffset(offset);
if (hasSubMinutePrecision) {
throw new RangeError(`Seconds not allowed in offset time zone: ${offset}`);
}
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
}

Expand Down Expand Up @@ -2199,7 +2203,28 @@ export function GetOffsetNanosecondsFor(timeZone, instant, getOffsetNanosecondsF

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

// In the spec, the code below only exists as part of GetOffsetStringFor.
// But in the polyfill, we re-use it to provide clearer error messages.
function formatOffsetStringNanoseconds(offsetNs) {
const offsetMinutes = MathTrunc(offsetNs / 6e10);
let offsetStringMinutes = FormatOffsetTimeZoneIdentifier(offsetMinutes * 6e10);
const subMinuteNanoseconds = MathAbs(offsetNs) % 6e10;
if (!subMinuteNanoseconds) return offsetStringMinutes;

// For offsets between -1s and 0, exclusive, FormatOffsetTimeZoneIdentifier's
// return value of "+00:00" is incorrect if there are sub-minute units.
if (!offsetMinutes && offsetNs < 0) offsetStringMinutes = '-00:00';

const seconds = MathFloor(subMinuteNanoseconds / 1e9) % 60;
const secondString = ISODateTimePartString(seconds);
const nanoseconds = subMinuteNanoseconds % 1e9;
if (!nanoseconds) return `${offsetStringMinutes}:${secondString}`;

let fractionString = `${nanoseconds}`.padStart(9, '0').replace(/0+$/, '');
return `${offsetStringMinutes}:${secondString}.${fractionString}`;
}

export function GetPlainDateTimeFor(timeZone, instant, calendar) {
Expand Down Expand Up @@ -2716,23 +2741,12 @@ export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {

export function FormatOffsetTimeZoneIdentifier(offsetNanoseconds) {
const sign = offsetNanoseconds < 0 ? '-' : '+';
offsetNanoseconds = MathAbs(offsetNanoseconds);
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');
while (fraction[fraction.length - 1] === '0') fraction = fraction.slice(0, -1);
post = `:${secondString}.${fraction}`;
} else if (seconds) {
post = `:${secondString}`;
}
return `${sign}${hourString}:${minuteString}${post}`;
const absoluteMinutes = MathAbs(offsetNanoseconds / 6e10);
const intHours = MathFloor(absoluteMinutes / 60);
const hh = ISODateTimePartString(intHours);
const intMinutes = absoluteMinutes % 60;
const mm = ISODateTimePartString(intMinutes);
return `${sign}${hh}:${mm}`;
}

export function FormatDateTimeUTCOffsetRounded(offsetNanoseconds) {
Expand Down
2 changes: 1 addition & 1 deletion polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +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 offsetIdentifier = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])?)?/;
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;

export const zoneddatetime = new RegExp(
Expand Down
14 changes: 8 additions & 6 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import assert from 'assert';
import * as ES from '../lib/ecmascript.mjs';
import { Instant } from '../lib/instant.mjs';

const timezoneNames = Intl.supportedValuesOf('timeZone');
const calendarNames = Intl.supportedValuesOf('calendar');
Expand Down Expand Up @@ -248,7 +249,12 @@ const temporalSign = withCode(
);
const temporalDecimalFraction = fraction;
function saveOffset(data, result) {
data.offset = ES.FormatOffsetTimeZoneIdentifier(ES.ParseDateTimeUTCOffset(result).offsetNanoseconds);
// To canonicalize an offset string that may include nanoseconds, we use GetOffsetStringFor
const instant = new Instant(0n);
const fakeTimeZone = {
getOffsetNanosecondsFor: () => ES.ParseDateTimeUTCOffset(result).offsetNanoseconds
};
data.offset = ES.GetOffsetStringFor(fakeTimeZone, instant);
}
const utcOffsetSubMinutePrecision = withCode(
seq(
Expand All @@ -262,11 +268,7 @@ const utcOffsetSubMinutePrecision = withCode(
saveOffset
);
const dateTimeUTCOffset = choice(utcDesignator, utcOffsetSubMinutePrecision);
const timeZoneUTCOffsetName = seq(
sign,
hour,
choice([minuteSecond, [minuteSecond, [fraction]]], seq(':', minuteSecond, [':', minuteSecond, [fraction]]))
);
const timeZoneUTCOffsetName = seq(sign, hour, choice([minuteSecond], seq(':', minuteSecond)));
const timeZoneIANAName = choice(...timezoneNames);
const timeZoneIdentifier = withCode(
choice(timeZoneUTCOffsetName, timeZoneIANAName),
Expand Down
2 changes: 1 addition & 1 deletion spec/abstractops.html
Original file line number Diff line number Diff line change
Expand Up @@ -1158,7 +1158,7 @@ <h1>ISO 8601 grammar</h1>
UTCOffsetSubMinutePrecision

TimeZoneUTCOffsetName :
UTCOffsetSubMinutePrecision
UTCOffsetMinutePrecision

TZLeadingChar :
Alpha
Expand Down
2 changes: 2 additions & 0 deletions spec/mainadditions.html
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ <h1>Time Zone Offset String <del>Format</del><ins>Formats</ins></h1>
<ins class="block">
<p>
ECMAScript defines string interchange formats for UTC offsets, derived from ISO 8601.
UTC offsets that represent offset time zone identifiers, or that are intended for interoperability with ISO 8601, use only hours and minutes and are specified by |UTCOffsetMinutePrecision|.
UTC offsets that represent the offset of a named or custom time zone can be more precise, and are specified by |UTCOffsetSubMinutePrecision|.
</p>
<p>
These formats are described by the ISO String grammar in <emu-xref href="#sec-temporal-iso8601grammar"></emu-xref>.
Expand Down
52 changes: 29 additions & 23 deletions spec/timezone.html
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ <h1>Properties of Temporal.TimeZone Instances</h1>
</td>
<td>
An integer for nanoseconds representing the constant offset of this time zone relative to UTC, or ~empty~ if the instance represents a named time zone.
If not ~empty~, this value must represent an integer number of minutes (i.e., [[OffsetNanoseconds]] modulo (6 × 10<sup>10</sup>) must always be 0).
If not ~empty~, this value must be in the interval from &minus;8.64 &times; 10<sup>13</sup> (exclusive) to &plus;8.64 &times; 10<sup>13</sup> (exclusive) (i.e., strictly less than 24 hours in magnitude).
</td>
</tr>
Expand Down Expand Up @@ -342,7 +343,7 @@ <h1>
Although the [[Identifier]] internal slot is a String in this specification, implementations may choose to store named time zone identifiers it in any other form (for example as an enumeration or index into a List of identifier strings) as long as the String can be regenerated when needed.
</p>
<p>
Similar flexibility exists for the storage of the [[OffsetNanoseconds]] internal slot, which can be interchangeably represented as a 6-byte signed integer or as a String value that may be as long as 19 characters.
Similar flexibility exists for the storage of the [[OffsetNanoseconds]] internal slot, which can be interchangeably represented as a 12-bit signed integer or as a 6-character ±HH:MM String value.
ParseTimeZoneIdentifier and FormatOffsetTimeZoneIdentifier may be used to losslessly convert one representation to the other.
Implementations are free to store either or both representations.
</p>
Expand Down Expand Up @@ -482,31 +483,20 @@ <h1>
<dd>
It formats a time zone offset, in nanoseconds, into a UTC offset string.
If _style_ is ~legacy~, then the output will be formatted like ±HHMM.
If _style_ is ~separated~, then the output will be formatted like ±HH:MM if _offsetNanoseconds_ represents an integer number of minutes, like ±HH:MM:SS if _offsetNanoseconds_ represents an integer number of seconds, and otherwise like ±HH:MM:SS.fff where "fff" is a sequence of at least 1 and at most 9 fractional seconds digits with no trailing zeroes.
If _style_ is ~separated~, then the output will be formatted like ±HH:MM.
</dd>
</dl>
<emu-alg>
1. If _offsetNanoseconds_ &ge; 0, let _sign_ be the code unit 0x002B (PLUS SIGN); otherwise, let _sign_ be the code unit 0x002D (HYPHEN-MINUS).
1. Set _offsetNanoseconds_ to abs(_offsetNanoseconds_).
1. Let _hours_ be floor(_offsetNanoseconds_ / (3.6 × 10<sup>12</sup>)).
1. Let _h_ be ToZeroPaddedDecimalString(_hours_, 2).
1. Let _minutes_ be floor(_offsetNanoseconds_ / (6 × 10<sup>10</sup>)) modulo 60.
1. Let _m_ be ToZeroPaddedDecimalString(_minutes_, 2).
1. Let _absoluteMinutes_ be abs(_offsetNanoseconds_ / (6 × 10<sup>10</sup>)).
1. Assert: _absoluteMinutes_ is an integer.
1. Let _intHours_ be floor(_absoluteMinutes_ / 60).
1. Let _hh_ be ToZeroPaddedDecimalString(_intHours_, 2).
1. Let _intMinutes_ be _absoluteMinutes_ modulo 60.
1. Let _mm_ be ToZeroPaddedDecimalString(_intMinutes_, 2).
1. If _style_ is ~legacy~, then
1. Assert _offsetNanoseconds_ modulo (6 × 10<sup>10</sup>) = 0.
1. Return the string-concatenation of _sign_, _h_, and _m_.
1. Let _seconds_ be floor(_offsetNanoseconds_ / 10<sup>9</sup>) modulo 60.
1. Let _s_ be ToZeroPaddedDecimalString(_seconds_, 2).
1. Let _nanoseconds_ be _offsetNanoseconds_ modulo 10<sup>9</sup>.
1. If _nanoseconds_ &ne; 0, then
1. Let _fraction_ be ToZeroPaddedDecimalString(_nanoseconds_, 9).
1. Set _fraction_ to the longest possible substring of _fraction_ starting at position 0 and not ending with the code unit 0x0030 (DIGIT ZERO).
1. Let _post_ be the string-concatenation of the code unit 0x003A (COLON), _s_, the code unit 0x002E (FULL STOP), and _fraction_.
1. Else if seconds &ne; 0, then
1. Let _post_ be the string-concatenation of the code unit 0x003A (COLON) and _s_.
1. Else,
1. Let _post_ be the empty String.
1. Return the string-concatenation of _sign_, _h_, the code unit 0x003A (COLON), _m_, and _post_.
1. Return the string-concatenation of _sign_, _hh_, and _mm_.
1. Return the string-concatenation of _sign_, _hh_, the code unit 0x003A (COLON), and _mm_.
</emu-alg>
</emu-clause>

Expand Down Expand Up @@ -657,11 +647,26 @@ <h1>
<dd>
This operation is the internal implementation of the `Temporal.TimeZone.prototype.getOffsetStringFor` method.
If the given _timeZone_ is an Object, it observably calls _timeZone_'s `getOffsetNanosecondsFor` method.
If the offset represents an integer number of minutes, then the output will be formatted like ±HH:MM.
Otherwise, the output will be formatted like ±HH:MM:SS or (if the offset does not evenly divide into seconds) ±HH:MM:SS.fff… where the "fff" part is a sequence of at least 1 and at most 9 fractional seconds digits with no trailing zeroes.
</dd>
</dl>
<emu-alg>
1. Let _offsetNanoseconds_ be ? GetOffsetNanosecondsFor(_timeZone_, _instant_).
1. Return FormatOffsetTimeZoneIdentifier(_offsetNanoseconds_, ~separated~).
1. Let _offsetMinutes_ be truncate(_offsetNanoseconds_ / (6 × 10<sup>10</sup>)).
1. Let _offsetString_ be FormatOffsetTimeZoneIdentifier(_offsetMinutes_ × (6 × 10<sup>10</sup>), ~separate~).
1. Let _subMinuteNanoseconds_ be abs(_offsetNanoseconds_) modulo (6 × 10<sup>10</sup>).
1. If _subMinuteNanoseconds_ = 0, then
1. Return _offsetString_.
1. If _offsetMinutes_ = 0 and _offsetNanoseconds_ &lt; 0, set _offsetString_ to *"-00:00"*.
1. Let _seconds_ be floor(_subMinuteNanoseconds_ / 10<sup>9</sup>) modulo 60.
1. Let _ss_ be ToZeroPaddedDecimalString(_seconds_, 2).
1. Let _nanoseconds_ be _subMinuteNanoseconds_ modulo 10<sup>9</sup>.
1. If _nanoseconds_ = 0, then
1. Return the string-concatenation of _offsetString_, the code unit 0x003A (COLON), and _ss_.
1. Let _fractionString_ be ToZeroPaddedDecimalString(_nanoseconds_, 9).
1. Set _fractionString_ to the longest prefix of _fractionString_ ending with a code unit other than 0x0030 (DIGIT ZERO).
1. Return the string-concatenation of _offsetString_, the code unit 0x003A (COLON), _ss_, the code unit 0x002E (FULL STOP), and _fractionString_.
</emu-alg>
</emu-clause>

Expand Down Expand Up @@ -826,7 +831,7 @@ <h1>
<dt>description</dt>
<dd>
If _identifier_ is a named time zone identifier, [[Name]] will be _identifier_ and [[OffsetNanoseconds]] will be ~empty~.
If _identifier_ is an offset time zone identifier, [[Name]] will be ~empty~ and [[OffsetNanoseconds]] will be a signed integer.
If _identifier_ is an offset time zone identifier, [[Name]] will be ~empty~ and [[OffsetNanoseconds]] will be a signed integer that is evenly divisible by 6 × 10<sup>10</sup>.
Otherwise, a *RangeError* will be thrown.
</dd>
</dl>
Expand All @@ -840,6 +845,7 @@ <h1>
1. Assert: _parseResult_ contains a |TimeZoneUTCOffsetName| Parse Node.
1. Let _offsetString_ be the source text matched by the |TimeZoneUTCOffsetName| Parse Node contained within _parseResult_.
1. Let _offsetNanoseconds_ be ! ParseDateTimeUTCOffset(_offsetString_).
1. Assert: _offsetNanoseconds_ modulo (6 × 10<sup>10</sup>) = 0.
1. Return the Record { [[Name]]: ~empty~, [[OffsetNanoseconds]]: _offsetNanoseconds_ }.
</emu-alg>
</emu-clause>
Expand Down

0 comments on commit bac5a1a

Please sign in to comment.