Skip to content

Commit

Permalink
Normative: Require 'T' prefix for ambiguous time-only strings
Browse files Browse the repository at this point in the history
ISO 8601 requires the 'T' prefix in cases where the time-only
representation is ambiguous.

Closes: #1765
  • Loading branch information
ptomato committed Dec 3, 2021
1 parent 115117b commit 04a13c5
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 27 deletions.
20 changes: 19 additions & 1 deletion polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,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);
Expand Down
5 changes: 3 additions & 2 deletions polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,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})$`);

Expand Down
27 changes: 27 additions & 0 deletions polyfill/test/plaintime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
108 changes: 86 additions & 22 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -221,14 +235,34 @@ 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) => {
result = result.slice(1);
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,
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Expand Down
84 changes: 82 additions & 2 deletions spec/abstractops.html
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,50 @@ <h1>ISO 8601 grammar</h1>
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?

Expand Down Expand Up @@ -898,6 +942,18 @@ <h1>ISO 8601 grammar</h1>
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
Expand Down Expand Up @@ -968,6 +1024,22 @@ <h1>ISO 8601 grammar</h1>
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?

CalendarDate :
Date Calendar?

Expand All @@ -978,7 +1050,9 @@ <h1>ISO 8601 grammar</h1>
Date TimeSpecSeparator? TimeZone?

CalendarTime :
TimeDesignator? TimeSpec TimeZone? Calendar?
TimeDesignator TimeSpec TimeZone? Calendar?
TimeSpec TimeZone? Calendar
TimeSpecWithOptionalTimeZoneNotAmbiguous

CalendarDateTime:
DateTime Calendar?
Expand Down Expand Up @@ -1113,8 +1187,11 @@ <h1>ParseISODateTime ( _isoString_ )</h1>
<emu-note>The value of ? ToIntegerOrInfinity(*undefined*) is 0.</emu-note>
<emu-alg>
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|, |TimeFractionalPart|, 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|, |TimeFractionalPart|, 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
Expand Down Expand Up @@ -1410,6 +1487,9 @@ <h1>ParseTemporalTimeString ( _isoString_ )</h1>
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]],
Expand Down

0 comments on commit 04a13c5

Please sign in to comment.