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