diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index 1bd5aa7c2c..248f841d7e 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -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, @@ -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]); @@ -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 } = @@ -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 }; diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index 615fa0a1df..33af5fc61f 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -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])/; @@ -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, diff --git a/polyfill/test/validStrings.mjs b/polyfill/test/validStrings.mjs index f3a9c3b661..f9b31d3146 100644 --- a/polyfill/test/validStrings.mjs +++ b/polyfill/test/validStrings.mjs @@ -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'); } @@ -152,6 +158,9 @@ class ZeroOrMore { return retval; } } +function zeroOrMore(production) { + return new ZeroOrMore(production); +} function oneOrMore(production) { return seq(production, new ZeroOrMore(production)); } @@ -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); @@ -266,7 +276,7 @@ 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; }); @@ -274,8 +284,18 @@ const timeZoneNameRequired = withCode(seq([timeZoneUTCOffset], timeZoneBracketed 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]])) @@ -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'); @@ -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 }; diff --git a/spec/abstractops.html b/spec/abstractops.html index eafdf8f33e..3e8e5c771b 100644 --- a/spec/abstractops.html +++ b/spec/abstractops.html @@ -826,8 +826,12 @@