From 6cbe8a5b50debf9578673538ec89b55d47ea0733 Mon Sep 17 00:00:00 2001 From: Justin Grant Date: Tue, 27 Jun 2023 19:34:58 -0700 Subject: [PATCH] Normative: Limit offset time zones to minutes 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 #2593. --- polyfill/lib/ecmascript.mjs | 55 +++++++++++++++++++++++----------- polyfill/lib/regex.mjs | 2 +- polyfill/test/validStrings.mjs | 14 +++++---- spec/abstractops.html | 2 +- spec/mainadditions.html | 2 ++ spec/timezone.html | 49 +++++++++++++++++------------- 6 files changed, 78 insertions(+), 46 deletions(-) diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index e7f04c0bc6..03bfe101e6 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -558,6 +558,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 }; } @@ -1400,7 +1401,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}`); } @@ -2105,7 +2106,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); } @@ -2169,7 +2173,33 @@ 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 === 0) 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 === 0 && offsetNs < 0) offsetStringMinutes = '-00:00'; + + const seconds = MathFloor(subMinuteNanoseconds / 1e9) % 60; + const secondString = ISODateTimePartString(seconds); + const nanoseconds = subMinuteNanoseconds % 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 `${offsetStringMinutes}${post}`; } export function GetPlainDateTimeFor(timeZone, instant, calendar) { @@ -2713,23 +2743,12 @@ export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) { export function FormatOffsetTimeZoneIdentifier(offsetNanoseconds) { const sign = offsetNanoseconds < 0 ? '-' : '+'; - offsetNanoseconds = MathAbs(offsetNanoseconds); - const hours = MathFloor(offsetNanoseconds / 3600e9); + const offsetMinutes = MathAbs(offsetNanoseconds / 6e10); + const hours = MathFloor(offsetMinutes / 60); const hourString = ISODateTimePartString(hours); - const minutes = MathFloor(offsetNanoseconds / 60e9) % 60; + const minutes = offsetMinutes % 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}`; + return `${sign}${hourString}:${minuteString}`; } export function FormatDateTimeUTCOffsetRounded(offsetNanoseconds) { diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index e07db6dffb..8a71c63841 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -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( diff --git a/polyfill/test/validStrings.mjs b/polyfill/test/validStrings.mjs index 554ff063a7..3ba6aa9b1f 100644 --- a/polyfill/test/validStrings.mjs +++ b/polyfill/test/validStrings.mjs @@ -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'); @@ -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( @@ -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), diff --git a/spec/abstractops.html b/spec/abstractops.html index 3c28c9e22f..b08121d8da 100644 --- a/spec/abstractops.html +++ b/spec/abstractops.html @@ -1119,7 +1119,7 @@

ISO 8601 grammar

UTCOffsetSubMinutePrecision TimeZoneUTCOffsetName : - UTCOffsetSubMinutePrecision + UTCOffsetMinutePrecision TZLeadingChar : Alpha diff --git a/spec/mainadditions.html b/spec/mainadditions.html index 2b31f66aae..4def70a0fe 100644 --- a/spec/mainadditions.html +++ b/spec/mainadditions.html @@ -310,6 +310,8 @@

Time Zone Offset String FormatFormats

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|.

These formats are described by the ISO String grammar in . diff --git a/spec/timezone.html b/spec/timezone.html index 026a7a50e3..6b81458755 100644 --- a/spec/timezone.html +++ b/spec/timezone.html @@ -284,6 +284,7 @@

Properties of Temporal.TimeZone Instances

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 × 1010) must always be 0). If not ~empty~, this value must be in the interval from −8.64 × 1013 (exclusive) to +8.64 × 1013 (exclusive) (i.e., strictly less than 24 hours in magnitude). @@ -342,7 +343,7 @@

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.

- 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.

@@ -482,31 +483,20 @@

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.
1. If _offsetNanoseconds_ ≥ 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 × 1012)). + 1. Assert: _offsetNanoseconds_ modulo (6 × 1010) = 0. + 1. Let _offsetMinutes_ be abs(_offsetNanoseconds_ / (6 × 1010)). + 1. Let _hours_ be floor(_offsetMinutes_ / 60). 1. Let _h_ be ToZeroPaddedDecimalString(_hours_, 2). - 1. Let _minutes_ be floor(_offsetNanoseconds_ / (6 × 1010)) modulo 60. + 1. Let _minutes_ be _offsetMinutes_ modulo 60. 1. Let _m_ be ToZeroPaddedDecimalString(_minutes_, 2). 1. If _style_ is ~legacy~, then - 1. Assert _offsetNanoseconds_ modulo (6 × 1010) = 0. 1. Return the string-concatenation of _sign_, _h_, and _m_. - 1. Let _seconds_ be floor(_offsetNanoseconds_ / 109) modulo 60. - 1. Let _s_ be ToZeroPaddedDecimalString(_seconds_, 2). - 1. Let _nanoseconds_ be _offsetNanoseconds_ modulo 109. - 1. If _nanoseconds_ ≠ 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 ≠ 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_, _h_, the code unit 0x003A (COLON), and _m_. @@ -657,11 +647,29 @@

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.
1. Let _offsetNanoseconds_ be ? GetOffsetNanosecondsFor(_timeZone_, _instant_). - 1. Return FormatOffsetTimeZoneIdentifier(_offsetNanoseconds_, ~separated~). + 1. Let _offsetMinutes_ be truncate(_offsetNanoseconds_ / (6 × 1010)). + 1. Let _offsetString_ be FormatOffsetTimeZoneIdentifier(_offsetMinutes_ × (6 × 1010), ~separate~). + 1. Let _subMinuteNanoseconds_ be abs(_offsetNanoseconds_) modulo (6 × 1010). + 1. If _subMinuteNanoseconds_ = 0, return _offsetString_. + 1. If _offsetMinutes_ = 0 and _offsetNanoseconds_ < 0, let _offsetString_ be *"-00:00"*. + 1. Let _seconds_ be floor(_subMinuteNanoseconds_ / 109) modulo 60. + 1. Let _s_ be ToZeroPaddedDecimalString(_seconds_, 2). + 1. Let _nanoseconds_ be _subMinuteNanoseconds_ modulo 109. + 1. If _nanoseconds_ ≠ 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 ≠ 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 _offsetString_ and _post_. @@ -826,7 +834,7 @@

description
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 × 1010. Otherwise, a *RangeError* will be thrown.
@@ -840,6 +848,7 @@

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 × 1010) = 0. 1. Return the Record { [[Name]]: ~empty~, [[OffsetNanoseconds]]: _offsetNanoseconds_ }.