diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index f9f674edd9..763b00b57b 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -346,7 +346,25 @@ export const ES = ObjectAssign({}, ES2020, { })); if (z) throw new RangeError('Z designator not supported for PlainTime'); } - return { hour, minute, second, millisecond, microsecond, nanosecond, calendar }; + // if it's a date-time string, OK + if (/[tT ][0-9][0-9]/.test(isoString)) { + return { hour, minute, second, millisecond, microsecond, nanosecond, calendar }; + } + // slow but non-grammar-dependent way to ensure that time-only strings that + // are also valid PlainMonthDay and PlainYearMonth throw. corresponds to + // assertion in spec text + try { + const { month, day } = ES.ParseTemporalMonthDayString(isoString); + ES.RejectISODate(1972, month, day); + } catch { + try { + const { year, month } = ES.ParseTemporalYearMonthString(isoString); + ES.RejectISODate(year, month, 1); + } catch { + return { hour, minute, second, millisecond, microsecond, nanosecond, calendar }; + } + } + throw new RangeError(`invalid ISO 8601 time-only string ${isoString}; may need a T prefix`); }, ParseTemporalYearMonthString: (isoString) => { const match = PARSE.yearmonth.exec(isoString); diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index f8b25e1619..4d5220ce58 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -28,8 +28,9 @@ export const time = new RegExp(`^T?${timesplit.source}(?:${zonesplit.source})?(? // The short forms of YearMonth and MonthDay are only for the ISO calendar. // Non-ISO calendar YearMonth and MonthDay have to parse as a Temporal.PlainDate, // with the reference fields. -// YYYYMM forbidden by ISO 8601, but since it is not ambiguous with anything -// else we could parse in a YearMonth context, we allow it +// 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})$`); diff --git a/polyfill/test/plaintime.mjs b/polyfill/test/plaintime.mjs index b193c52fe3..bdceb64adb 100644 --- a/polyfill/test/plaintime.mjs +++ b/polyfill/test/plaintime.mjs @@ -1018,6 +1018,33 @@ describe('Time', () => { equal(`${PlainTime.from('T15:23:30')}`, '15:23:30'); equal(`${PlainTime.from('t152330')}`, '15:23:30'); }); + it('time designator required for ambiguous strings', () => { + // YYYY-MM or HHMM-UU + throws(() => PlainTime.from('2021-12'), RangeError); + equal(`${PlainTime.from('T2021-12')}`, '20:21:00'); + equal(`${PlainTime.from('2021-13')}`, '20:21:00'); + equal(`${PlainTime.from('0000-00')}`, '00:00:00'); + // MMDD or HHMM + throws(() => PlainTime.from('1214'), RangeError); + throws(() => PlainTime.from('0229'), RangeError); + throws(() => PlainTime.from('1130'), RangeError); + equal(`${PlainTime.from('T1214')}`, '12:14:00'); + equal(`${PlainTime.from('1314')}`, '13:14:00'); + equal(`${PlainTime.from('1232')}`, '12:32:00'); + equal(`${PlainTime.from('0230')}`, '02:30:00'); + equal(`${PlainTime.from('0631')}`, '06:31:00'); + equal(`${PlainTime.from('0000')}`, '00:00:00'); + // MM-DD or HH-UU + throws(() => PlainTime.from('12-14'), RangeError); + equal(`${PlainTime.from('T12-14')}`, '12:00:00'); + equal(`${PlainTime.from('13-14')}`, '13:00:00'); + equal(`${PlainTime.from('00-00')}`, '00:00:00'); + // YYYYMM or HHMMSS + throws(() => PlainTime.from('202112'), RangeError); + equal(`${PlainTime.from('T202112')}`, '20:21:12'); + equal(`${PlainTime.from('202113')}`, '20:21:13'); + equal(`${PlainTime.from('000000')}`, '00:00:00'); + }); it('no implicit midnight from date-only string', () => { throws(() => PlainTime.from('1976-11-18'), RangeError); }); diff --git a/polyfill/test/validStrings.mjs b/polyfill/test/validStrings.mjs index 7038f5d821..05f5c41fe7 100644 --- a/polyfill/test/validStrings.mjs +++ b/polyfill/test/validStrings.mjs @@ -204,12 +204,26 @@ const dateYear = withCode( const dateMonth = withCode(zeroPaddedInclusive(1, 12, 2), (data, result) => (data.month = +result)); const dateDay = withCode(zeroPaddedInclusive(1, 31, 2), (data, result) => (data.day = +result)); -const timeHour = withCode(hour, (data, result) => (data.hour = +result)); -const timeMinute = withCode(minuteSecond, (data, result) => (data.minute = +result)); -const timeSecond = withCode(choice(minuteSecond, '60'), (data, result) => { +function saveHour(data, result) { + data.hour = +result; +} +function saveMinute(data, result) { + data.minute = +result; +} +function saveSecond(data, result) { data.second = +result; if (data.second === 60) data.second = 59; -}); +} +const timeHour = withCode(hour, saveHour); +const timeMinute = withCode(minuteSecond, saveMinute); +const timeSecond = withCode(choice(minuteSecond, '60'), saveSecond); +const timeHourNotValidMonth = withCode(choice('00', zeroPaddedInclusive(13, 23, 2)), saveHour); +const timeHourNot31DayMonth = withCode(choice('02', '04', '06', '09', '11'), saveHour); +const timeHour2Only = withCode('02', saveHour); +const timeMinuteNotValidDay = withCode(choice('00', zeroPaddedInclusive(32, 59, 2)), saveMinute); +const timeMinute30Only = withCode('30', saveMinute); +const timeMinute31Only = withCode('31', saveMinute); +const timeSecondNotValidMonth = withCode(choice('00', zeroPaddedInclusive(13, 60, 2)), saveSecond); const timeFraction = withCode(fraction, (data, result) => { result = result.slice(1); const fraction = result.padEnd(9, '0'); @@ -221,7 +235,11 @@ const timeZoneUTCOffsetSign = withCode( sign, (data, result) => (data.offsetSign = result === '-' || result === '\u2212' ? '-' : '+') ); -const timeZoneUTCOffsetHour = withCode(hour, (data, result) => (data.offsetHour = +result)); +function saveOffsetHour(data, result) { + data.offsetHour = +result; +} +const timeZoneUTCOffsetHour = withCode(hour, saveOffsetHour); +const timeZoneUTCOffsetHourNotValidMonth = withCode(zeroPaddedInclusive(13, 23, 2), saveOffsetHour); const timeZoneUTCOffsetMinute = withCode(minuteSecond, (data, result) => (data.offsetMinute = +result)); const timeZoneUTCOffsetSecond = withCode(minuteSecond, (data, result) => (data.offsetSecond = +result)); const timeZoneUTCOffsetFraction = withCode(fraction, (data, result) => { @@ -229,6 +247,22 @@ const timeZoneUTCOffsetFraction = withCode(fraction, (data, result) => { const fraction = result.padEnd(9, '0'); data.offsetFraction = +fraction; }); +function saveOffset(data) { + if (data.offsetSign !== undefined && data.offsetHour !== undefined) { + const h = `${data.offsetHour}`.padStart(2, '0'); + const m = `${data.offsetMinute || 0}`.padStart(2, '0'); + const s = `${data.offsetSecond || 0}`.padStart(2, '0'); + data.offset = `${data.offsetSign}${h}:${m}`; + if (data.offsetFraction) { + let fraction = `${data.offsetFraction}`.padStart(9, '0'); + while (fraction.endsWith('0')) fraction = fraction.slice(0, -1); + data.offset += `:${s}.${fraction}`; + } else if (data.offsetSecond) { + data.offset += `:${s}`; + } + if (data.offset === '-00:00') data.offset = '+00:00'; + } +} const timeZoneNumericUTCOffset = withCode( seq( timeZoneUTCOffsetSign, @@ -238,22 +272,25 @@ const timeZoneNumericUTCOffset = withCode( seq(':', timeZoneUTCOffsetMinute, [':', timeZoneUTCOffsetSecond, [timeZoneUTCOffsetFraction]]) ) ), - (data) => { - if (data.offsetSign !== undefined && data.offsetHour !== undefined) { - const h = `${data.offsetHour}`.padStart(2, '0'); - const m = `${data.offsetMinute || 0}`.padStart(2, '0'); - const s = `${data.offsetSecond || 0}`.padStart(2, '0'); - data.offset = `${data.offsetSign}${h}:${m}`; - if (data.offsetFraction) { - let fraction = `${data.offsetFraction}`.padStart(9, '0'); - while (fraction.endsWith('0')) fraction = fraction.slice(0, -1); - data.offset += `:${s}.${fraction}`; - } else if (data.offsetSecond) { - data.offset += `:${s}`; - } - if (data.offset === '-00:00') data.offset = '+00:00'; - } - } + saveOffset +); +const timeZoneNumericUTCOffsetNotAmbiguous = withCode( + choice( + seq(character('+\u2212'), timeZoneUTCOffsetHour), + seq( + timeZoneUTCOffsetSign, + timeZoneUTCOffsetHour, + choice( + seq(timeZoneUTCOffsetMinute, [timeZoneUTCOffsetSecond, [timeZoneUTCOffsetFraction]]), + seq(':', timeZoneUTCOffsetMinute, [':', timeZoneUTCOffsetSecond, [timeZoneUTCOffsetFraction]]) + ) + ) + ), + saveOffset +); +const timeZoneNumericUTCOffsetNotAmbiguousAllowedNegativeHour = withCode( + choice(timeZoneNumericUTCOffsetNotAmbiguous, seq('-', timeZoneUTCOffsetHourNotValidMonth)), + saveOffset ); const timeZoneUTCOffset = choice(utcDesignator, timeZoneNumericUTCOffset); const timeZoneUTCOffsetName = seq( @@ -286,6 +323,29 @@ const timeSpec = seq( timeHour, choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]])) ); +const timeSpecWithOptionalTimeZoneNotAmbiguous = choice( + seq(timeHour, [timeZoneNumericUTCOffsetNotAmbiguous], [timeZoneBracketedAnnotation]), + seq(timeHourNotValidMonth, timeZone), + seq( + choice( + seq(timeHourNotValidMonth, timeMinute), + seq(timeHour, timeMinuteNotValidDay), + seq(timeHourNot31DayMonth, timeMinute31Only), + seq(timeHour2Only, timeMinute30Only) + ), + [timeZoneBracketedAnnotation] + ), + seq( + timeHour, + timeMinute, + choice( + seq(timeZoneNumericUTCOffsetNotAmbiguousAllowedNegativeHour, [timeZoneBracketedAnnotation]), + seq(timeSecondNotValidMonth, [timeZone]), + seq(timeSecond, timeFraction, [timeZone]) + ) + ), + seq(timeHour, ':', timeMinute, [':', timeSecond, [timeFraction]], [timeZone]) +); const timeSpecSeparator = seq(dateTimeSeparator, timeSpec); const dateSpecMonthDay = seq(['--'], dateMonth, ['-'], dateDay); @@ -294,7 +354,11 @@ const date = choice(seq(dateYear, '-', dateMonth, '-', dateDay), seq(dateYear, d const dateTime = seq(date, [timeSpecSeparator], [timeZone]); const calendarDateTime = seq(dateTime, [calendar]); const calendarDateTimeTimeRequired = seq(date, timeSpecSeparator, [timeZone], [calendar]); -const calendarTime = seq([timeDesignator], timeSpec, [timeZone], [calendar]); +const calendarTime = choice( + seq(timeDesignator, timeSpec, [timeZone], [calendar]), + seq(timeSpec, [timeZone], calendar), + seq(timeSpecWithOptionalTimeZoneNotAmbiguous) +); const durationFractionalPart = withCode(between(1, 9, digit()), (data, result) => { const fraction = result.padEnd(9, '0'); diff --git a/spec/abstractops.html b/spec/abstractops.html index 39bd601918..db9c3c7961 100644 --- a/spec/abstractops.html +++ b/spec/abstractops.html @@ -822,6 +822,50 @@

ISO 8601 grammar

MinuteSecond `60` + TimeHourNotValidMonth : one of + `00` `13` `14` `15` `16` `17` `18` `19` `20` `21` `23` + + TimeHourNotThirtyOneDayMonth : one of + `02` `04` `06` `09` `11` + + TimeHourTwoOnly : + `02` + + TimeMinuteNotValidDay : + `00` + `32` + `33` + `34` + `35` + `36` + `37` + `38` + `39` + `4` DecimalDigit + `5` DecimalDigit + `60` + + TimeMinuteThirtyOnly : + `30` + + TimeMinuteThirtyOneOnly : + `31` + + TimeSecondNotValidMonth : + `00` + `13` + `14` + `15` + `16` + `17` + `18` + `19` + `2` DecimalDigit + `3` DecimalDigit + `4` DecimalDigit + `5` DecimalDigit + `60` + FractionalPart : DecimalDigit DecimalDigit? DecimalDigit? DecimalDigit? DecimalDigit? DecimalDigit? DecimalDigit? DecimalDigit? DecimalDigit? @@ -860,6 +904,18 @@

ISO 8601 grammar

TimeZoneNumericUTCOffset UTCDesignator + TimeZoneNumericUTCOffsetNotAmbiguous : + `+` TimeZoneUTCOffsetHour + U+2212 TimeZoneUTCOffsetHour + TimeZoneUTCOffsetSign TimeZoneUTCOffsetHour `:` TimeZoneUTCOffsetMinute + TimeZoneUTCOffsetSign TimeZoneUTCOffsetHour TimeZoneUTCOffsetMinute + TimeZoneUTCOffsetSign TimeZoneUTCOffsetHour `:` TimeZoneUTCOffsetMinute `:` TimeZoneUTCOffsetSecond TimeZoneUTCOffsetFraction? + TimeZoneUTCOffsetSign TimeZoneUTCOffsetHour TimeZoneUTCOffsetMinute TimeZoneUTCOffsetSecond TimeZoneUTCOffsetFraction? + + TimeZoneNumericUTCOffsetNotAmbiguousAllowedNegativeHour : + TimeZoneNumericUTCOffsetNotAmbiguous + `-` TimeHourNotValidMonth + TimeZoneUTCOffsetName : Sign Hour Sign Hour `:` MinuteSecond @@ -930,14 +986,35 @@

ISO 8601 grammar

TimeHour `:` TimeMinute `:` TimeSecond TimeFraction? TimeHour TimeMinute TimeSecond TimeFraction? + TimeHourMinuteBasicFormatNotAmbiguous : + TimeHourNotValidMonth TimeMinute + TimeHour TimeMinuteNotValidDay + TimeHourNotThirtyOneDayMonth TimeMinuteThirtyOneOnly + TimeHourTwoOnly TimeMinuteThirtyOnly + + TimeSpecWithOptionalTimeZoneNotAmbiguous : + TimeHour TimeZoneNumericUTCOffsetNotAmbiguous? TimeZoneBracketedAnnotation? + TimeHourNotValidMonth TimeZone + TimeHour `:` TimeMinute TimeZone? + TimeHourMinuteBasicFormatNotAmbiguous TimeZoneBracketedAnnotation? + TimeHour TimeMinute TimeZoneNumericUTCOffsetNotAmbiguousAllowedNegativeHour TimeZoneBracketedAnnotation? + TimeHour `:` TimeMinute `:` TimeSecond TimeFraction? TimeZone? + TimeHour TimeMinute TimeSecondNotValidMonth TimeZone? + TimeHour TimeMinute TimeSecond TimeFraction TimeZone? + TimeSpecSeparator : DateTimeSeparator TimeSpec DateTime : Date TimeSpecSeparator? TimeZone? + CalendarDate : + Date Calendar? + CalendarTime : - TimeDesignator? TimeSpec TimeZone? Calendar? + TimeDesignator TimeSpec TimeZone? Calendar? + TimeSpec TimeZone? Calendar + TimeSpecWithOptionalTimeZoneNotAmbiguous CalendarDateTime: DateTime Calendar? @@ -1071,7 +1148,11 @@

ParseISODateTime ( _isoString_ )

The value of ? ToIntegerOrInfinity(*undefined*) is 0. 1. Assert: Type(_isoString_) is String. - 1. Let _year_, _month_, _day_, _hour_, _minute_, _second_, _fraction_, and _calendar_ be the parts of _isoString_ produced respectively by the |DateYear|, |DateMonth|, |DateDay|, |TimeHour|, |TimeMinute|, |TimeSecond|, |TimeFraction|, and |CalendarName| productions, or *undefined* if not present. + 1. Let _year_, _month_, _day_, _fraction_, and _calendar_ be the parts of _isoString_ produced respectively by the |DateYear|, |DateMonth|, |DateDay|, |TimeFraction|, and |CalendarName| productions, or *undefined* if not present. + 1. Let _year_ be the part of _isoString_ produced by the |DateYear| production. + 1. Let _hour_ be the part of _isoString_ produced by the |TimeHour|, |TimeHourNotValidMonth|, |TimeHourNotThirtyOneDayMonth|, or |TimeHourTwoOnly| productions, or *undefined* if none of those are present. + 1. Let _minute_ be the part of _isoString_ produced by the |TimeMinute|, |TimeMinuteNotValidDay|, |TimeMinuteThirtyOnly|, or |TimeMinuteThirtyOneOnly| productions, or *undefined* if none of those are present. + 1. Let _second_ be the part of _isoString_ produced by the |TimeSecond| or |TimeSecondNotValidMonth| productions, or *undefined* if neither of those are present. 1. If the first code unit of _year_ is 0x2212 (MINUS SIGN), replace it with the code unit 0x002D (HYPHEN-MINUS). 1. Set _year_ to ! ToIntegerOrInfinity(_year_). 1. If _month_ is *undefined*, then @@ -1360,6 +1441,9 @@

ParseTemporalTimeString ( _isoString_ )

1. If _isoString_ contains a |UTCDesignator|, then 1. Throw a *RangeError* exception. 1. Let _result_ be ? ParseISODateTime(_isoString_). + 1. Assert: ParseText(! StringToCodePoints(_isoString_), |CalendarDate|) is a List of errors. + 1. Assert: ParseText(! StringToCodePoints(_isoString_), |DateSpecYearMonth|) is a List of errors. + 1. Assert: ParseText(! StringToCodePoints(_isoString_), |DateSpecMonthDay|) is a List of errors. 1. Return the Record { [[Hour]]: _result_.[[Hour]], [[Minute]]: _result_.[[Minute]],