Skip to content

Commit b733c21

Browse files
committed
PlainTime: disallow date strings, support T prefix
Port of two overlapping PRs (the latter is a revision of the former): * tc39/proposal-temporal#1952 * tc39/proposal-temporal#1986
1 parent f883736 commit b733c21

File tree

5 files changed

+161
-32
lines changed

5 files changed

+161
-32
lines changed

lib/ecmascript.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ function ParseISODateTime(isoString: string) {
326326
const month = ToInteger(match[2] || match[4]);
327327
const day = ToInteger(match[3] || match[5]);
328328
const hour = ToInteger(match[6]);
329+
const hasTime = match[6] !== undefined;
329330
const minute = ToInteger(match[7] || match[10]);
330331
let second = ToInteger(match[8] || match[11]);
331332
if (second === 60) second = 59;
@@ -367,6 +368,7 @@ function ParseISODateTime(isoString: string) {
367368
year,
368369
month,
369370
day,
371+
hasTime,
370372
hour,
371373
minute,
372374
second,
@@ -414,11 +416,31 @@ function ParseTemporalTimeString(isoString: string) {
414416
nanosecond = ToInteger(fraction.slice(6, 9));
415417
calendar = match[15];
416418
} else {
417-
let z;
418-
({ hour, minute, second, millisecond, microsecond, nanosecond, calendar, z } = ParseISODateTime(isoString));
419+
let z, hasTime;
420+
({ hasTime, hour, minute, second, millisecond, microsecond, nanosecond, calendar, z } =
421+
ParseISODateTime(isoString));
422+
if (!hasTime) throw new RangeError(`time is missing in string: ${isoString}`);
419423
if (z) throw new RangeError('Z designator not supported for PlainTime');
420424
}
421-
return { hour, minute, second, millisecond, microsecond, nanosecond, calendar };
425+
// if it's a date-time string, OK
426+
if (/[tT ][0-9][0-9]/.test(isoString)) {
427+
return { hour, minute, second, millisecond, microsecond, nanosecond, calendar };
428+
}
429+
// slow but non-grammar-dependent way to ensure that time-only strings that
430+
// are also valid PlainMonthDay and PlainYearMonth throw. corresponds to
431+
// assertion in spec text
432+
try {
433+
const { month, day } = ParseTemporalMonthDayString(isoString);
434+
RejectISODate(1972, month, day);
435+
} catch {
436+
try {
437+
const { year, month } = ParseTemporalYearMonthString(isoString);
438+
RejectISODate(year, month, 1);
439+
} catch {
440+
return { hour, minute, second, millisecond, microsecond, nanosecond, calendar };
441+
}
442+
}
443+
throw new RangeError(`invalid ISO 8601 time-only string ${isoString}; may need a T prefix`);
422444
}
423445

424446
function ParseTemporalYearMonthString(isoString: string) {

lib/regex.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ export const zoneddatetime = new RegExp(
2323
'i'
2424
);
2525

26-
export const time = new RegExp(`^${timesplit.source}(?:${zonesplit.source})?(?:${calendar.source})?$`, 'i');
26+
export const time = new RegExp(`^T?${timesplit.source}(?:${zonesplit.source})?(?:${calendar.source})?$`, 'i');
2727

2828
// The short forms of YearMonth and MonthDay are only for the ISO calendar.
2929
// Non-ISO calendar YearMonth and MonthDay have to parse as a Temporal.PlainDate,
3030
// with the reference fields.
31-
// YYYYMM forbidden by ISO 8601, but since it is not ambiguous with anything
32-
// else we could parse in a YearMonth context, we allow it
31+
// YYYYMM forbidden by ISO 8601 because ambiguous with YYMMDD, but allowed by
32+
// RFC 3339 and we don't allow 2-digit years, so we allow it.
33+
// Not ambiguous with HHMMSS because that requires a 'T' prefix
3334
export const yearmonth = new RegExp(`^(${yearpart.source})-?(${monthpart.source})$`);
3435
export const monthday = new RegExp(`^(?:--)?(${monthpart.source})-?(${daypart.source})$`);
3536

test/plaintime.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,44 @@ describe('Time', () => {
12631263
it('optional parts', () => {
12641264
equal(`${PlainTime.from('15')}`, '15:00:00');
12651265
});
1266+
it('date-only formats not allowed', () => {
1267+
throws(() => PlainTime.from('2020-12-01'), RangeError);
1268+
throws(() => PlainTime.from('20201201'), RangeError);
1269+
});
1270+
it('time designator prefix', () => {
1271+
equal(`${PlainTime.from('T15:23:30')}`, '15:23:30');
1272+
equal(`${PlainTime.from('t152330')}`, '15:23:30');
1273+
});
1274+
it('space not accepted as time designator prefix', () => {
1275+
throws(() => PlainTime.from(' 15:23:30'), RangeError);
1276+
});
1277+
it('time designator required for ambiguous strings', () => {
1278+
// YYYY-MM or HHMM-UU
1279+
throws(() => PlainTime.from('2021-12'), RangeError);
1280+
equal(`${PlainTime.from('T2021-12')}`, '20:21:00');
1281+
equal(`${PlainTime.from('2021-13')}`, '20:21:00');
1282+
equal(`${PlainTime.from('0000-00')}`, '00:00:00');
1283+
// MMDD or HHMM
1284+
throws(() => PlainTime.from('1214'), RangeError);
1285+
throws(() => PlainTime.from('0229'), RangeError);
1286+
throws(() => PlainTime.from('1130'), RangeError);
1287+
equal(`${PlainTime.from('T1214')}`, '12:14:00');
1288+
equal(`${PlainTime.from('1314')}`, '13:14:00');
1289+
equal(`${PlainTime.from('1232')}`, '12:32:00');
1290+
equal(`${PlainTime.from('0230')}`, '02:30:00');
1291+
equal(`${PlainTime.from('0631')}`, '06:31:00');
1292+
equal(`${PlainTime.from('0000')}`, '00:00:00');
1293+
// MM-DD or HH-UU
1294+
throws(() => PlainTime.from('12-14'), RangeError);
1295+
equal(`${PlainTime.from('T12-14')}`, '12:00:00');
1296+
equal(`${PlainTime.from('13-14')}`, '13:00:00');
1297+
equal(`${PlainTime.from('00-00')}`, '00:00:00');
1298+
// YYYYMM or HHMMSS
1299+
throws(() => PlainTime.from('202112'), RangeError);
1300+
equal(`${PlainTime.from('T202112')}`, '20:21:12');
1301+
equal(`${PlainTime.from('202113')}`, '20:21:13');
1302+
equal(`${PlainTime.from('000000')}`, '00:00:00');
1303+
});
12661304
it('no junk at end of string', () => throws(() => PlainTime.from('15:23:30.100junk'), RangeError));
12671305
it('options may only be an object or undefined', () => {
12681306
[null, 1, 'hello', true, Symbol('foo'), 1n].forEach((badOptions) =>

test/regex.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,11 @@ describe('fromString regex', () => {
297297
test('19761118T152330.1234', [15, 23, 30, 123, 400]);
298298
// Representations with reduced precision
299299
test('1976-11-18T15', [15]);
300-
test('1976-11-18', []);
301300
// Time-only forms
301+
['T', 't', ''].forEach((prefix) => generateTest(`${prefix}15:23`, ''));
302+
test('T15', [15]);
303+
test('T1523', [15, 23]);
304+
test('T152330', [15, 23, 30]);
302305
generateTest('15:23', '');
303306
['+01:00[Europe/Vienna]', '[Europe/Vienna]', '+01:00[Custom/Vienna]', '-04:00', 'Z', ''].forEach((zoneStr) =>
304307
test(`15${zoneStr}`, [15])

test/validStrings.mjs

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ const monthsDesignator = character('Mm');
185185
const durationDesignator = character('Pp');
186186
const secondsDesignator = character('Ss');
187187
const dateTimeSeparator = character(' Tt');
188-
const durationTimeDesignator = character('Tt');
188+
const timeDesignator = character('Tt');
189189
const weeksDesignator = character('Ww');
190190
const yearsDesignator = character('Yy');
191191
const utcDesignator = withCode(character('Zz'), (data) => {
@@ -204,12 +204,26 @@ const dateYear = withCode(
204204
const dateMonth = withCode(zeroPaddedInclusive(1, 12, 2), (data, result) => (data.month = +result));
205205
const dateDay = withCode(zeroPaddedInclusive(1, 31, 2), (data, result) => (data.day = +result));
206206

207-
const timeHour = withCode(hour, (data, result) => (data.hour = +result));
208-
const timeMinute = withCode(minuteSecond, (data, result) => (data.minute = +result));
209-
const timeSecond = withCode(choice(minuteSecond, '60'), (data, result) => {
207+
function saveHour(data, result) {
208+
data.hour = +result;
209+
}
210+
function saveMinute(data, result) {
211+
data.minute = +result;
212+
}
213+
function saveSecond(data, result) {
210214
data.second = +result;
211215
if (data.second === 60) data.second = 59;
212-
});
216+
}
217+
const timeHour = withCode(hour, saveHour);
218+
const timeMinute = withCode(minuteSecond, saveMinute);
219+
const timeSecond = withCode(choice(minuteSecond, '60'), saveSecond);
220+
const timeHourNotValidMonth = withCode(choice('00', zeroPaddedInclusive(13, 23, 2)), saveHour);
221+
const timeHourNot31DayMonth = withCode(choice('02', '04', '06', '09', '11'), saveHour);
222+
const timeHour2Only = withCode('02', saveHour);
223+
const timeMinuteNotValidDay = withCode(choice('00', zeroPaddedInclusive(32, 59, 2)), saveMinute);
224+
const timeMinute30Only = withCode('30', saveMinute);
225+
const timeMinute31Only = withCode('31', saveMinute);
226+
const timeSecondNotValidMonth = withCode(choice('00', zeroPaddedInclusive(13, 60, 2)), saveSecond);
213227
const timeFraction = withCode(fraction, (data, result) => {
214228
result = result.slice(1);
215229
const fraction = result.padEnd(9, '0');
@@ -221,14 +235,34 @@ const timeZoneUTCOffsetSign = withCode(
221235
sign,
222236
(data, result) => (data.offsetSign = result === '-' || result === '\u2212' ? '-' : '+')
223237
);
224-
const timeZoneUTCOffsetHour = withCode(hour, (data, result) => (data.offsetHour = +result));
238+
function saveOffsetHour(data, result) {
239+
data.offsetHour = +result;
240+
}
241+
const timeZoneUTCOffsetHour = withCode(hour, saveOffsetHour);
242+
const timeZoneUTCOffsetHourNotValidMonth = withCode(zeroPaddedInclusive(13, 23, 2), saveOffsetHour);
225243
const timeZoneUTCOffsetMinute = withCode(minuteSecond, (data, result) => (data.offsetMinute = +result));
226244
const timeZoneUTCOffsetSecond = withCode(minuteSecond, (data, result) => (data.offsetSecond = +result));
227245
const timeZoneUTCOffsetFraction = withCode(fraction, (data, result) => {
228246
result = result.slice(1);
229247
const fraction = result.padEnd(9, '0');
230248
data.offsetFraction = +fraction;
231249
});
250+
function saveOffset(data) {
251+
if (data.offsetSign !== undefined && data.offsetHour !== undefined) {
252+
const h = `${data.offsetHour}`.padStart(2, '0');
253+
const m = `${data.offsetMinute || 0}`.padStart(2, '0');
254+
const s = `${data.offsetSecond || 0}`.padStart(2, '0');
255+
data.offset = `${data.offsetSign}${h}:${m}`;
256+
if (data.offsetFraction) {
257+
let fraction = `${data.offsetFraction}`.padStart(9, '0');
258+
while (fraction.endsWith('0')) fraction = fraction.slice(0, -1);
259+
data.offset += `:${s}.${fraction}`;
260+
} else if (data.offsetSecond) {
261+
data.offset += `:${s}`;
262+
}
263+
if (data.offset === '-00:00') data.offset = '+00:00';
264+
}
265+
}
232266
const timeZoneNumericUTCOffset = withCode(
233267
seq(
234268
timeZoneUTCOffsetSign,
@@ -238,22 +272,25 @@ const timeZoneNumericUTCOffset = withCode(
238272
seq(':', timeZoneUTCOffsetMinute, [':', timeZoneUTCOffsetSecond, [timeZoneUTCOffsetFraction]])
239273
)
240274
),
241-
(data) => {
242-
if (data.offsetSign !== undefined && data.offsetHour !== undefined) {
243-
const h = `${data.offsetHour}`.padStart(2, '0');
244-
const m = `${data.offsetMinute || 0}`.padStart(2, '0');
245-
const s = `${data.offsetSecond || 0}`.padStart(2, '0');
246-
data.offset = `${data.offsetSign}${h}:${m}`;
247-
if (data.offsetFraction) {
248-
let fraction = `${data.offsetFraction}`.padStart(9, '0');
249-
while (fraction.endsWith('0')) fraction = fraction.slice(0, -1);
250-
data.offset += `:${s}.${fraction}`;
251-
} else if (data.offsetSecond) {
252-
data.offset += `:${s}`;
253-
}
254-
if (data.offset === '-00:00') data.offset = '+00:00';
255-
}
256-
}
275+
saveOffset
276+
);
277+
const timeZoneNumericUTCOffsetNotAmbiguous = withCode(
278+
choice(
279+
seq(character('+\u2212'), timeZoneUTCOffsetHour),
280+
seq(
281+
timeZoneUTCOffsetSign,
282+
timeZoneUTCOffsetHour,
283+
choice(
284+
seq(timeZoneUTCOffsetMinute, [timeZoneUTCOffsetSecond, [timeZoneUTCOffsetFraction]]),
285+
seq(':', timeZoneUTCOffsetMinute, [':', timeZoneUTCOffsetSecond, [timeZoneUTCOffsetFraction]])
286+
)
287+
)
288+
),
289+
saveOffset
290+
);
291+
const timeZoneNumericUTCOffsetNotAmbiguousAllowedNegativeHour = withCode(
292+
choice(timeZoneNumericUTCOffsetNotAmbiguous, seq('-', timeZoneUTCOffsetHourNotValidMonth)),
293+
saveOffset
257294
);
258295
const timeZoneUTCOffset = choice(utcDesignator, timeZoneNumericUTCOffset);
259296
const timeZoneUTCOffsetName = seq(
@@ -286,14 +323,42 @@ const timeSpec = seq(
286323
timeHour,
287324
choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]]))
288325
);
326+
const timeSpecWithOptionalTimeZoneNotAmbiguous = choice(
327+
seq(timeHour, [timeZoneNumericUTCOffsetNotAmbiguous], [timeZoneBracketedAnnotation]),
328+
seq(timeHourNotValidMonth, timeZone),
329+
seq(
330+
choice(
331+
seq(timeHourNotValidMonth, timeMinute),
332+
seq(timeHour, timeMinuteNotValidDay),
333+
seq(timeHourNot31DayMonth, timeMinute31Only),
334+
seq(timeHour2Only, timeMinute30Only)
335+
),
336+
[timeZoneBracketedAnnotation]
337+
),
338+
seq(
339+
timeHour,
340+
timeMinute,
341+
choice(
342+
seq(timeZoneNumericUTCOffsetNotAmbiguousAllowedNegativeHour, [timeZoneBracketedAnnotation]),
343+
seq(timeSecondNotValidMonth, [timeZone]),
344+
seq(timeSecond, timeFraction, [timeZone])
345+
)
346+
),
347+
seq(timeHour, ':', timeMinute, [':', timeSecond, [timeFraction]], [timeZone])
348+
);
289349
const timeSpecSeparator = seq(dateTimeSeparator, timeSpec);
290350

291351
const dateSpecMonthDay = seq(['--'], dateMonth, ['-'], dateDay);
292352
const dateSpecYearMonth = seq(dateYear, ['-'], dateMonth);
293353
const date = choice(seq(dateYear, '-', dateMonth, '-', dateDay), seq(dateYear, dateMonth, dateDay));
294354
const dateTime = seq(date, [timeSpecSeparator], [timeZone]);
295355
const calendarDateTime = seq(dateTime, [calendar]);
296-
const calendarTime = seq(timeSpec, [timeZone], [calendar]);
356+
const calendarDateTimeTimeRequired = seq(date, timeSpecSeparator, [timeZone], [calendar]);
357+
const calendarTime = choice(
358+
seq(timeDesignator, timeSpec, [timeZone], [calendar]),
359+
seq(timeSpec, [timeZone], calendar),
360+
seq(timeSpecWithOptionalTimeZoneNotAmbiguous)
361+
);
297362

298363
const durationFractionalPart = withCode(between(1, 9, digit()), (data, result) => {
299364
const fraction = result.padEnd(9, '0');
@@ -317,7 +382,7 @@ const durationHours = seq(
317382
hoursDesignator,
318383
[choice(durationMinutes, durationSeconds)]
319384
);
320-
const durationTime = seq(durationTimeDesignator, choice(durationHours, durationMinutes, durationSeconds));
385+
const durationTime = seq(timeDesignator, choice(durationHours, durationMinutes, durationSeconds));
321386
const durationDays = seq(
322387
withCode(oneOrMore(digit()), (data, result) => (data.days = +result * data.factor)),
323388
daysDesignator
@@ -354,7 +419,7 @@ const goals = {
354419
DateTime: calendarDateTime,
355420
Duration: duration,
356421
MonthDay: choice(dateSpecMonthDay, calendarDateTime),
357-
Time: choice(calendarTime, calendarDateTime),
422+
Time: choice(calendarTime, calendarDateTimeTimeRequired),
358423
TimeZone: choice(temporalTimeZoneIdentifier, seq(date, [timeSpecSeparator], timeZone, [calendar])),
359424
YearMonth: choice(dateSpecYearMonth, calendarDateTime),
360425
ZonedDateTime: zonedDateTime

0 commit comments

Comments
 (0)