Skip to content

Commit

Permalink
Normative: Align ISO 8601 grammar with annotations from IXDTF
Browse files Browse the repository at this point in the history
The IETF SEDATE Working Group Internet-Draft, "Date and Time on the
Internet: Timestamps with additional information" has reached agreement on
all the open issues. This implements the conclusions of that draft RFC,
which defines a date-time format called Internet Extended Date-Time Format
(abbreviated IXDTF).

IXDTF defines a grammar and semantics for annotations that can be appended
to RFC 3339 strings. We were already using these annotations informally in
Temporal for time zones and calendars. The main things that have to change
are that annotations can have a "critical" flag ("!") which in Temporal
has no effect on time zone and calendar annotations; and that multiple
annotations are possible, where unknown ones are ignored unless they are
marked critical.

See: #1450
  • Loading branch information
ptomato committed Aug 31, 2022
1 parent 38f8f80 commit 2167646
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 68 deletions.
34 changes: 24 additions & 10 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,15 @@ export const ES = ObjectAssign({}, ES2020, {
if (offset === '-00:00') offset = '+00:00';
}
const ianaName = match[19];
const calendar = match[20];
const annotations = match[20];
let calendar;
for (const [, critical, key, value] of annotations.matchAll(PARSE.annotation)) {
if (key === 'u-ca') {
if (calendar === undefined) calendar = value;
} else if (critical === '!') {
throw new RangeError(`Unrecognized annotation: !${key}=${value}`);
}
}
ES.RejectDateTime(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
return {
year,
Expand Down Expand Up @@ -365,7 +373,7 @@ export const ES = ObjectAssign({}, ES2020, {
},
ParseTemporalTimeString: (isoString) => {
const match = PARSE.time.exec(isoString);
let hour, minute, second, millisecond, microsecond, nanosecond, calendar;
let hour, minute, second, millisecond, microsecond, nanosecond, annotations, calendar;
if (match) {
hour = ES.ToInteger(match[1]);
minute = ES.ToInteger(match[2] || match[5]);
Expand All @@ -375,7 +383,14 @@ export const ES = ObjectAssign({}, ES2020, {
millisecond = ES.ToInteger(fraction.slice(0, 3));
microsecond = ES.ToInteger(fraction.slice(3, 6));
nanosecond = ES.ToInteger(fraction.slice(6, 9));
calendar = match[15];
annotations = match[15];
for (const [, critical, key, value] of annotations.matchAll(PARSE.annotation)) {
if (key === 'u-ca') {
if (calendar === undefined) calendar = value;
} else if (critical === '!') {
throw new RangeError(`Unrecognized annotation: !${key}=${value}`);
}
}
} else {
let z, hasTime;
({ hasTime, hour, minute, second, millisecond, microsecond, nanosecond, calendar, z } =
Expand All @@ -388,18 +403,17 @@ 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)])
// The annotations must be stripped so presence of a calendar doesn't result
// in interpretation of otherwise ambiguous input as a time.
const isoStringWithoutAnnotations = annotations
? ES.Call(StringPrototypeSlice, isoString, [0, -annotations.length])
: isoString;
try {
const { month, day } = ES.ParseTemporalMonthDayString(isoStringWithoutCalendar);
const { month, day } = ES.ParseTemporalMonthDayString(isoStringWithoutAnnotations);
ES.RejectISODate(1972, month, day);
} catch {
try {
const { year, month } = ES.ParseTemporalYearMonthString(isoStringWithoutCalendar);
const { year, month } = ES.ParseTemporalYearMonthString(isoStringWithoutAnnotations);
ES.RejectISODate(year, month, 1);
} catch {
return { hour, minute, second, millisecond, microsecond, nanosecond, calendar };
Expand Down
11 changes: 4 additions & 7 deletions polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ export const timeZoneID = new RegExp(
')'
);

const calComponent = /[A-Za-z0-9]{3,8}/;
export const calendarID = new RegExp(`(?:${calComponent.source}(?:-${calComponent.source})*)`);

const yearpart = /(?:[+\u2212-]\d{6}|\d{4})/;
const monthpart = /(?:0[1-9]|1[0-2])/;
const daypart = /(?:0[1-9]|[12]\d|3[01])/;
Expand All @@ -26,15 +23,15 @@ export const datesplit = new RegExp(
);
const timesplit = /(\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?|(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?)?/;
export const offset = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])(?::?([0-5][0-9])(?:[.,](\d{1,9}))?)?)?/;
const zonesplit = new RegExp(`(?:([zZ])|(?:${offset.source})?)(?:\\[(${timeZoneID.source})\\])?`);
const calendar = new RegExp(`\\[u-ca=(${calendarID.source})\\]`);
const zonesplit = new RegExp(`(?:([zZ])|(?:${offset.source})?)(?:\\[!?(${timeZoneID.source})\\])?`);
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;

export const zoneddatetime = new RegExp(
`^${datesplit.source}(?:(?:T|\\s+)${timesplit.source})?${zonesplit.source}(?:${calendar.source})?$`,
`^${datesplit.source}(?:(?:T|\\s+)${timesplit.source})?${zonesplit.source}((?:${annotation.source})*)$`,
'i'
);

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

// 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,
Expand Down
52 changes: 36 additions & 16 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ class CharacterClass extends Choice {
function character(str) {
return new CharacterClass(str);
}
function lcalpha() {
return new CharacterClass('abcdefghijklmnopqrstuvwxyz');
}
function alpha() {
return new CharacterClass('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
}
function digit() {
return new CharacterClass('0123456789');
}
Expand Down Expand Up @@ -152,6 +158,9 @@ class ZeroOrMore {
return retval;
}
}
function zeroOrMore(production) {
return new ZeroOrMore(production);
}
function oneOrMore(production) {
return seq(production, new ZeroOrMore(production));
}
Expand Down Expand Up @@ -196,6 +205,7 @@ const yearsDesignator = character('Yy');
const utcDesignator = withCode(character('Zz'), (data) => {
data.z = 'Z';
});
const annotationCriticalFlag = character('!');
const timeFractionalPart = between(1, 9, digit());
const fraction = seq(decimalSeparator, timeFractionalPart);

Expand Down Expand Up @@ -266,16 +276,26 @@ const timeZoneIdentifier = withCode(
choice(timeZoneUTCOffsetName, timeZoneIANAName),
(data, result) => (data.ianaName = result)
);
const timeZoneBracketedAnnotation = seq('[', timeZoneIdentifier, ']');
const timeZoneBracketedAnnotation = seq('[', [annotationCriticalFlag], timeZoneIdentifier, ']');
const timeZoneOffsetRequired = withCode(seq(timeZoneUTCOffset, [timeZoneBracketedAnnotation]), (data) => {
if (!('offset' in data)) data.offset = undefined;
});
const timeZoneNameRequired = withCode(seq([timeZoneUTCOffset], timeZoneBracketedAnnotation), (data) => {
if (!('offset' in data)) data.offset = undefined;
});
const timeZone = choice(timeZoneOffsetRequired, timeZoneNameRequired);
const calendarName = withCode(choice(...calendarNames), (data, result) => (data.calendar = result));
const calendar = seq('[u-ca=', calendarName, ']');
const aKeyLeadingChar = choice(lcalpha(), character('_'));
const aKeyChar = choice(lcalpha(), digit(), character('_-'));
const aValChar = choice(alpha(), digit());
const annotationKey = seq(aKeyLeadingChar, zeroOrMore(aKeyChar));
const annotationValueComponent = oneOrMore(aValChar);
const annotationValue = seq(annotationValueComponent, zeroOrMore(seq('-', annotationValueComponent)));
const annotation = seq('[', /*[annotationCriticalFlag],*/ annotationKey, '=', annotationValue, ']');
const calendarName = withCode(choice(...calendarNames), (data, result) => {
if (!data.calendar) data.calendar = result;
});
const calendarAnnotation = seq('[', [annotationCriticalFlag], 'u-ca=', calendarName, ']');
const annotations = oneOrMore(choice(calendarAnnotation, annotation));
const timeSpec = seq(
timeHour,
choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]]))
Expand All @@ -300,12 +320,12 @@ const date = withSyntaxConstraints(
validateDayOfMonth
);
const dateTime = seq(date, [timeSpecSeparator], [timeZone]);
const calendarDateTime = seq(dateTime, [calendar]);
const calendarDateTimeTimeRequired = seq(date, timeSpecSeparator, [timeZone], [calendar]);
const calendarTime = choice(
seq(timeDesignator, timeSpec, [timeZone], [calendar]),
seq(timeSpecWithOptionalTimeZoneNotAmbiguous, [calendar])
const annotatedTime = choice(
seq(timeDesignator, timeSpec, [timeZone], [annotations]),
seq(timeSpecWithOptionalTimeZoneNotAmbiguous, [annotations])
);
const annotatedDateTime = seq(dateTime, [annotations]);
const annotatedDateTimeTimeRequired = seq(date, timeSpecSeparator, [timeZone], [annotations]);

const durationFractionalPart = withCode(between(1, 9, digit()), (data, result) => {
const fraction = result.padEnd(9, '0');
Expand Down Expand Up @@ -359,19 +379,19 @@ const duration = seq(
choice(durationDate, durationTime)
);

const instant = seq(date, [timeSpecSeparator], timeZoneOffsetRequired, [calendar]);
const zonedDateTime = seq(date, [timeSpecSeparator], timeZoneNameRequired, [calendar]);
const instant = seq(date, [timeSpecSeparator], timeZoneOffsetRequired, [annotations]);
const zonedDateTime = seq(date, [timeSpecSeparator], timeZoneNameRequired, [annotations]);

// goal elements
const goals = {
Instant: instant,
Date: calendarDateTime,
DateTime: calendarDateTime,
Date: annotatedDateTime,
DateTime: annotatedDateTime,
Duration: duration,
MonthDay: choice(dateSpecMonthDay, calendarDateTime),
Time: choice(calendarTime, calendarDateTimeTimeRequired),
TimeZone: choice(timeZoneIdentifier, seq(date, [timeSpecSeparator], timeZone, [calendar])),
YearMonth: choice(dateSpecYearMonth, calendarDateTime),
MonthDay: choice(dateSpecMonthDay, annotatedDateTime),
Time: choice(annotatedTime, annotatedDateTimeTimeRequired),
TimeZone: choice(timeZoneIdentifier, seq(date, [timeSpecSeparator], timeZone, [annotations])),
YearMonth: choice(dateSpecYearMonth, annotatedDateTime),
ZonedDateTime: zonedDateTime
};

Expand Down
Loading

0 comments on commit 2167646

Please sign in to comment.