From 03bff3f30eca4a4eed5f1a0455f39fa568031254 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 31 Aug 2022 17:09:29 -0700 Subject: [PATCH] Normative: Allow annotations after YYYY-MM and MM-DD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In keeping with the IXDTF format which extends the grammar of RFC 3339 with any number of annotations, we should allow annotations (time zone, calendar, and unknown after #2397 lands) after the short YYYY-MM PlainYearMonth and MM-DD PlainMonthDay forms. If we were to allow UTC offsets ±UU[:UU] alongside the time zone annotation, that would be ambiguous in one case: YYYY-MM-UU would be ambiguous with YYYY-MM-DD. This PR makes the following choices: - UTC offset is allowed after MM-DD - UTC offset is disallowed after YYYY-MM (even if it is positive, or contains a minute component, which would not be ambiguous) Other choices are certainly possible. Closes: #2379 --- polyfill/lib/ecmascript.mjs | 24 ++++++++++++++---------- polyfill/lib/regex.mjs | 13 ++++++++++--- polyfill/test/validStrings.mjs | 21 +++++++++++++++++---- spec/abstractops.html | 29 +++++++++++++++++++++++------ 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index 6347492837..6a9fd664af 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -18,7 +18,6 @@ const ObjectDefineProperty = Object.defineProperty; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const ObjectIs = Object.is; const ObjectEntries = Object.entries; -const StringPrototypeSlice = String.prototype.slice; import bigInt from 'big-integer'; import Call from 'es-abstract/2020/Call.js'; @@ -388,18 +387,12 @@ export const ES = ObjectAssign({}, ES2020, { return { hour, minute, second, millisecond, microsecond, nanosecond, calendar }; } // Reject strings that are ambiguous with PlainMonthDay or PlainYearMonth. - // The calendar suffix is `[u-ca=${calendar}]`, i.e. calendar plus 7 characters, - // and must be stripped so presence of a calendar doesn't result in interpretation - // of otherwise ambiguous input as a time. - const isoStringWithoutCalendar = calendar - ? ES.Call(StringPrototypeSlice, isoString, [0, -(calendar.length + 7)]) - : isoString; try { - const { month, day } = ES.ParseTemporalMonthDayString(isoStringWithoutCalendar); + const { month, day } = ES.ParseTemporalMonthDayString(isoString); ES.RejectISODate(1972, month, day); } catch { try { - const { year, month } = ES.ParseTemporalYearMonthString(isoStringWithoutCalendar); + const { year, month } = ES.ParseTemporalYearMonthString(isoString); ES.RejectISODate(year, month, 1); } catch { return { hour, minute, second, millisecond, microsecond, nanosecond, calendar }; @@ -417,6 +410,9 @@ export const ES = ObjectAssign({}, ES2020, { year = ES.ToInteger(yearString); month = ES.ToInteger(match[2]); calendar = match[3]; + if (calendar !== undefined && calendar !== 'iso8601') { + throw new RangeError('YYYY-MM format is only valid with iso8601 calendar'); + } } else { let z; ({ year, month, calendar, day: referenceISODay, z } = ES.ParseISODateTime(isoString)); @@ -430,6 +426,10 @@ export const ES = ObjectAssign({}, ES2020, { if (match) { month = ES.ToInteger(match[1]); day = ES.ToInteger(match[2]); + calendar = match[10]; + if (calendar !== undefined && calendar != 'iso8601') { + throw new RangeError('MM-DD format is only valid with iso8601 calendar'); + } } else { let z; ({ month, day, calendar, year: referenceISOYear, z } = ES.ParseISODateTime(isoString)); @@ -1628,7 +1628,11 @@ export const ES = ObjectAssign({}, ES2020, { try { ({ calendar } = ES.ParseISODateTime(identifier)); } catch { - throw new RangeError(`Invalid calendar: ${identifier}`); + try { + ({ calendar } = ES.ParseTemporalYearMonthString(identifier)); + } catch { + ({ calendar } = ES.ParseTemporalMonthDayString(identifier)); + } } if (!calendar) calendar = 'iso8601'; return new TemporalCalendar(calendar); diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index 615fa0a1df..f11a64c361 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -36,14 +36,21 @@ export const zoneddatetime = new RegExp( export const time = new RegExp(`^T?${timesplit.source}(?:${zonesplit.source})?(?:${calendar.source})?$`, 'i'); -// The short forms of YearMonth and MonthDay are only for the ISO calendar. +// The short forms of YearMonth and MonthDay are only for the ISO calendar, but +// a calendar annotation is still allowed and will throw if not ISO. // Non-ISO calendar YearMonth and MonthDay have to parse as a Temporal.PlainDate, // with the reference fields. // YYYYMM forbidden by ISO 8601 because ambiguous with YYMMDD, but allowed by // RFC 3339 and we don't allow 2-digit years, so we allow it. // Not ambiguous with HHMMSS because that requires a 'T' prefix -export const yearmonth = new RegExp(`^(${yearpart.source})-?(${monthpart.source})$`); -export const monthday = new RegExp(`^(?:--)?(${monthpart.source})-?(${daypart.source})$`); +// In YearMonth, a time zone annotation is allowed but no UTC offset, because +// YYYY-MM-UU is ambiguous with YYYY-MM-DD +export const yearmonth = new RegExp( + `^(${yearpart.source})-?(${monthpart.source})(?:\\[${timeZoneID.source}\\])?(?:${calendar.source})?$` +); +export const monthday = new RegExp( + `^(?:--)?(${monthpart.source})-?(${daypart.source})(?:${zonesplit.source})?(?:${calendar.source})?$` +); const fraction = /(\d+)(?:[.,](\d{1,9}))?/; diff --git a/polyfill/test/validStrings.mjs b/polyfill/test/validStrings.mjs index f3a9c3b661..1eea75ebc4 100644 --- a/polyfill/test/validStrings.mjs +++ b/polyfill/test/validStrings.mjs @@ -281,10 +281,10 @@ const timeSpec = seq( choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]])) ); const timeSpecWithOptionalTimeZoneNotAmbiguous = withSyntaxConstraints(seq(timeSpec, [timeZone]), (result) => { - if (/^(?:(?!02-?30)(?:0[1-9]|1[012])-?(?:0[1-9]|[12][0-9]|30)|(?:0[13578]|10|12)-?31)$/.test(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'); } - if (/^(?![−-]000000)(?:[0-9]{4}|[+−-][0-9]{6})-?(?:0[1-9]|1[012])$/.test(result)) { + if (/^(?![−-]000000)(?:[0-9]{4}|[+−-][0-9]{6})-?(?:0[1-9]|1[012])/.test(result)) { throw new SyntaxError('valid PlainYearMonth'); } }); @@ -306,6 +306,19 @@ const calendarTime = choice( seq(timeDesignator, timeSpec, [timeZone], [calendar]), seq(timeSpecWithOptionalTimeZoneNotAmbiguous, [calendar]) ); +const annotatedYearMonth = withSyntaxConstraints( + seq(dateSpecYearMonth, [timeZoneBracketedAnnotation], [calendar]), + (result, data) => { + if (data.calendar !== undefined && data.calendar !== 'iso8601') { + throw new SyntaxError('retry if YYYY-MM with non-ISO calendar'); + } + } +); +const annotatedMonthDay = withSyntaxConstraints(seq(dateSpecMonthDay, [timeZone], [calendar]), (result, data) => { + if (data.calendar !== undefined && data.calendar !== 'iso8601') { + throw new SyntaxError('retry if MM-DD with non-ISO calendar'); + } +}); const durationFractionalPart = withCode(between(1, 9, digit()), (data, result) => { const fraction = result.padEnd(9, '0'); @@ -368,10 +381,10 @@ const goals = { Date: calendarDateTime, DateTime: calendarDateTime, Duration: duration, - MonthDay: choice(dateSpecMonthDay, calendarDateTime), + MonthDay: choice(annotatedMonthDay, calendarDateTime), Time: choice(calendarTime, calendarDateTimeTimeRequired), TimeZone: choice(timeZoneIdentifier, seq(date, [timeSpecSeparator], timeZone, [calendar])), - YearMonth: choice(dateSpecYearMonth, calendarDateTime), + YearMonth: choice(annotatedYearMonth, calendarDateTime), ZonedDateTime: zonedDateTime }; diff --git a/spec/abstractops.html b/spec/abstractops.html index f5b2964674..98e77295dc 100644 --- a/spec/abstractops.html +++ b/spec/abstractops.html @@ -1096,8 +1096,14 @@

ISO 8601 grammar

TimeHour `:` TimeMinute `:` TimeSecond TimeFraction? TimeHour TimeMinute TimeSecond TimeFraction? + AmbiguousMonthDay : + ValidMonthDay TimeZone? + + AmbiguousYearMonth : + DateSpecYearMonth TimeZoneBracketedAnnotation? + TimeSpecWithOptionalTimeZoneNotAmbiguous : - TimeSpec TimeZone? but not one of ValidMonthDay or DateSpecYearMonth + TimeSpec TimeZone? but not one of AmbiguousMonthDay or AmbiguousYearMonth TimeSpecSeparator : DateTimeSeparator TimeSpec @@ -1115,6 +1121,12 @@

ISO 8601 grammar

CalendarDateTimeTimeRequired : Date TimeSpecSeparator TimeZone? Calendar? + AnnotatedYearMonth: + DateSpecYearMonth TimeZoneBracketedAnnotation? Calendar? + + AnnotatedMonthDay: + DateSpecMonthDay TimeZone? Calendar? + DurationWholeSeconds : DecimalDigits[~Sep] @@ -1195,7 +1207,7 @@

ISO 8601 grammar

Duration TemporalMonthDayString : - DateSpecMonthDay + AnnotatedMonthDay CalendarDateTime TemporalTimeString : @@ -1203,7 +1215,7 @@

ISO 8601 grammar

CalendarDateTimeTimeRequired TemporalYearMonthString : - DateSpecYearMonth + AnnotatedYearMonth CalendarDateTime TemporalZonedDateTimeString : @@ -1214,8 +1226,8 @@

ISO 8601 grammar

TemporalInstantString CalendarDateTime CalendarTime - DateSpecYearMonth - DateSpecMonthDay + AnnotatedYearMonth + AnnotatedMonthDay