Skip to content

Commit

Permalink
Normative: Allow annotations after YYYY-MM and MM-DD
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ptomato committed Sep 1, 2022
1 parent b4d9abc commit 03bff3f
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 23 deletions.
24 changes: 14 additions & 10 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand All @@ -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));
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}))?/;

Expand Down
21 changes: 17 additions & 4 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
Expand All @@ -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');
Expand Down Expand Up @@ -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
};

Expand Down
29 changes: 23 additions & 6 deletions spec/abstractops.html
Original file line number Diff line number Diff line change
Expand Up @@ -1096,8 +1096,14 @@ <h1>ISO 8601 grammar</h1>
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
Expand All @@ -1115,6 +1121,12 @@ <h1>ISO 8601 grammar</h1>
CalendarDateTimeTimeRequired :
Date TimeSpecSeparator TimeZone? Calendar?

AnnotatedYearMonth:
DateSpecYearMonth TimeZoneBracketedAnnotation? Calendar?

AnnotatedMonthDay:
DateSpecMonthDay TimeZone? Calendar?

DurationWholeSeconds :
DecimalDigits[~Sep]

Expand Down Expand Up @@ -1195,15 +1207,15 @@ <h1>ISO 8601 grammar</h1>
Duration

TemporalMonthDayString :
DateSpecMonthDay
AnnotatedMonthDay
CalendarDateTime

TemporalTimeString :
CalendarTime
CalendarDateTimeTimeRequired

TemporalYearMonthString :
DateSpecYearMonth
AnnotatedYearMonth
CalendarDateTime

TemporalZonedDateTimeString :
Expand All @@ -1214,8 +1226,8 @@ <h1>ISO 8601 grammar</h1>
TemporalInstantString
CalendarDateTime
CalendarTime
DateSpecYearMonth
DateSpecMonthDay
AnnotatedYearMonth
AnnotatedMonthDay
</emu-grammar>
<ul>
<li>
Expand All @@ -1237,8 +1249,13 @@ <h1>
<emu-note>The value of ! ToIntegerOrInfinity(*undefined*) is 0.</emu-note>
<emu-alg>
1. Let _parseResult_ be ~empty~.
1. For each nonterminal _goal_ of &laquo; |TemporalDateTimeString|, |TemporalInstantString|, |TemporalMonthDayString|, |TemporalTimeString|, |TemporalYearMonthString|, |TemporalZonedDateTimeString| &raquo;, do
1. For each nonterminal _goal_ of &laquo; |TemporalDateTimeString|, |TemporalInstantString|, |TemporalTimeString|, |TemporalZonedDateTimeString| &raquo;, do
1. If _parseResult_ is not a Parse Node, set _parseResult_ to ParseText(StringToCodePoints(_isoString_), _goal_).
1. For each nonterminal _goal_ of &laquo; |TemporalMonthDayString|, |TemporalYearMonthString| &raquo;, do
1. If _parseResult_ is not a Parse Node, then
1. Set _parseResult_ to ParseText(StringToCodePoints(_isoString_), _goal_).
1. If _parseResult_ contains a |CalendarName| Parse Node _c_, and CodePointsToString(_c_) is not *"iso8601"*, then
1. Throw a *RangeError* exception.
1. If _parseResult_ is not a Parse Node, throw a *RangeError* exception.
1. Let each of _year_, _month_, _day_, _hour_, _minute_, _second_, _fSeconds_, and _calendar_ be the source text matched by the respective |DateYear|, |DateMonth|, |DateDay|, |TimeHour|, |TimeMinute|, |TimeSecond|, |TimeFraction|, and |CalendarName| Parse Node contained within _parseResult_, or an empty sequence of code points if not present.
1. If the first code point of _year_ is U+2212 (MINUS SIGN), replace the first code point with U+002D (HYPHEN-MINUS).
Expand Down

0 comments on commit 03bff3f

Please sign in to comment.