From ce1433579b265b6215e1496d0f53486101f7240c Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 16 Oct 2020 15:43:53 -0700 Subject: [PATCH] Define valid calendar IDs A valid calendar ID according to BCP 47 is a number of components consisting of 2 to 8 ASCII alphanumerics each, joined by dashes. Add this to the grammar and enforce it in custom calendars. See: #665 --- docs/calendar.md | 2 + polyfill/lib/calendar.mjs | 4 ++ polyfill/lib/regex.mjs | 5 ++- polyfill/test/instant.mjs | 2 +- polyfill/test/regex.mjs | 29 ++++++++++++- polyfill/test/usercalendar.mjs | 74 +++++++++++++++++----------------- spec/abstractops.html | 30 +++++++++++--- spec/calendar.html | 2 + 8 files changed, 103 insertions(+), 45 deletions(-) diff --git a/docs/calendar.md b/docs/calendar.md index 9a30761ddc..77e894da5b 100644 --- a/docs/calendar.md +++ b/docs/calendar.md @@ -41,6 +41,8 @@ For specialized applications where you need to do calculations in a calendar sys To do this, create a class inheriting from `Temporal.Calendar`, call `super()` in the constructor with a calendar identifier, and implement all the methods. Any subclass of `Temporal.Calendar` will be accepted in Temporal APIs where a built-in `Temporal.Calendar` would work. +The identifier of a custom calendar must consist of one or more components of between 3 and 8 ASCII alphanumeric characters each, separated by dashes, as described in [Unicode Technical Standard 35](https://unicode.org/reports/tr35/tr35.html#Unicode_locale_identifier). + ### Protocol It's also possible for a plain object to be a custom calendar, without subclassing. diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 7a33c30c38..3b623b50dd 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -2,10 +2,14 @@ import { ES } from './ecmascript.mjs'; import { GetIntrinsic, MakeIntrinsicClass, DefineIntrinsic } from './intrinsicclass.mjs'; +import * as REGEX from './regex.mjs'; import { CALENDAR_ID, ISO_YEAR, ISO_MONTH, ISO_DAY, CreateSlots, GetSlot, SetSlot } from './slots.mjs'; +const ID_REGEX = new RegExp(`^${REGEX.calendarID.source}$`); + export class Calendar { constructor(id) { + if (!ID_REGEX.exec(id)) throw new RangeError(`invalid calendar identifier ${id}`); CreateSlots(this); SetSlot(this, CALENDAR_ID, id); diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index 18d077344a..460fc4dea2 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -1,9 +1,12 @@ +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 datesplit = new RegExp(`(${yearpart.source})(?:-(\\d{2})-(\\d{2})|(\\d{2})(\\d{2}))`); const timesplit = /(\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?|(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?)?/; export const offset = /([+-\u2212])([0-2][0-9])(?::?([0-5][0-9]))?/; const zonesplit = new RegExp(`(?:([zZ])|(?:${offset.source}?(?:\\[(?!c=)([^\\]\\s]*)?\\])?))`); -const calendar = /\[c=([^\]\s]+)\]/; +const calendar = new RegExp(`\\[c=(${calendarID.source})\\]`); export const instant = new RegExp( `^${datesplit.source}(?:T|\\s+)${timesplit.source}${zonesplit.source}(?:${calendar.source})?$`, diff --git a/polyfill/test/instant.mjs b/polyfill/test/instant.mjs index e75cc4555c..16942b9eff 100644 --- a/polyfill/test/instant.mjs +++ b/polyfill/test/instant.mjs @@ -356,7 +356,7 @@ describe('Instant', () => { equal(`${Instant.from('1976-11-18T15Z')}`, '1976-11-18T15:00Z'); }); it('ignores any specified calendar', () => - equal(`${Instant.from('1976-11-18T15:23:30.123456789Z[c=discordian]')}`, '1976-11-18T15:23:30.123456789Z')); + equal(`${Instant.from('1976-11-18T15:23:30.123456789Z[c=discord]')}`, '1976-11-18T15:23:30.123456789Z')); it('no junk at end of string', () => throws(() => Instant.from('1976-11-18T15:23:30.123456789Zjunk'), RangeError)); }); describe('Instant.add works', () => { diff --git a/polyfill/test/regex.mjs b/polyfill/test/regex.mjs index 0c3b82bc5e..dd9499e968 100644 --- a/polyfill/test/regex.mjs +++ b/polyfill/test/regex.mjs @@ -5,7 +5,7 @@ import Pretty from '@pipobscure/demitasse-pretty'; const { reporter } = Pretty; import { strict as assert } from 'assert'; -const { equal } = assert; +const { equal, throws } = assert; import * as Temporal from 'proposal-temporal'; @@ -534,6 +534,33 @@ describe('fromString regex', () => { } } }); + + describe('calendar ID', () => { + function makeCustomCalendar(id) { + return class extends Temporal.Calendar { + constructor() { + super(id); + } + }; + } + describe('valid', () => { + ['aaa', 'aaa-aaa', 'eightZZZ', 'eightZZZ-eightZZZ'].forEach((id) => { + it(id, () => { + const Custom = makeCustomCalendar(id); + const calendar = new Custom(); + equal(calendar.id, id); + }); + }); + }); + describe('not valid', () => { + ['a', 'a-a', 'aa', 'aa-aa', 'foo_', 'foo.', 'ninechars', 'ninechars-ninechars'].forEach((id) => { + it(id, () => { + const Custom = makeCustomCalendar(id); + throws(() => new Custom(), RangeError); + }); + }); + }); + }); }); import { normalize } from 'path'; diff --git a/polyfill/test/usercalendar.mjs b/polyfill/test/usercalendar.mjs index 512d24b86c..12478bbaef 100644 --- a/polyfill/test/usercalendar.mjs +++ b/polyfill/test/usercalendar.mjs @@ -21,7 +21,7 @@ describe('Userland calendar', () => { const ISO8601Calendar = Temporal.Calendar.from('iso8601').constructor; class ZeroBasedCalendar extends ISO8601Calendar { constructor() { - super('zerobased'); + super('zero-based'); } dateFromFields(fields, options, constructor) { fields.month++; @@ -47,13 +47,13 @@ describe('Userland calendar', () => { const md = Temporal.MonthDay.from({ month: 5, day: 5, calendar: obj }); it('is a calendar', () => equal(typeof obj, 'object')); - it('.id property', () => equal(obj.id, 'zerobased')); + it('.id property', () => equal(obj.id, 'zero-based')); // FIXME: what should happen in Temporal.Calendar.from(obj)? it('.id is not available in from()', () => { - throws(() => Temporal.Calendar.from('zerobased'), RangeError); - throws(() => Temporal.Calendar.from('2020-06-05T09:34-07:00[America/Vancouver][c=zerobased]'), RangeError); + throws(() => Temporal.Calendar.from('zero-based'), RangeError); + throws(() => Temporal.Calendar.from('2020-06-05T09:34-07:00[America/Vancouver][c=zero-based]'), RangeError); }); - it('Temporal.Date.from()', () => equal(`${date}`, '2020-06-05[c=zerobased]')); + it('Temporal.Date.from()', () => equal(`${date}`, '2020-06-05[c=zero-based]')); it('Temporal.Date fields', () => { equal(date.year, 2020); equal(date.month, 5); @@ -67,7 +67,7 @@ describe('Userland calendar', () => { const date2 = Temporal.Date.from('2020-06-05'); assert(date2.withCalendar(obj).equals(date)); }); - it('Temporal.DateTime.from()', () => equal(`${dt}`, '2020-06-05T12:00[c=zerobased]')); + it('Temporal.DateTime.from()', () => equal(`${dt}`, '2020-06-05T12:00[c=zero-based]')); it('Temporal.DateTime fields', () => { equal(dt.year, 2020); equal(dt.month, 5); @@ -87,7 +87,7 @@ describe('Userland calendar', () => { const dt2 = Temporal.DateTime.from('2020-06-05T12:00'); assert(dt2.withCalendar(obj).equals(dt)); }); - it('Temporal.YearMonth.from()', () => equal(`${ym}`, '2020-06-01[c=zerobased]')); + it('Temporal.YearMonth.from()', () => equal(`${ym}`, '2020-06-01[c=zero-based]')); it('Temporal.YearMonth fields', () => { equal(dt.year, 2020); equal(dt.month, 5); @@ -96,7 +96,7 @@ describe('Userland calendar', () => { const ym2 = ym.with({ month: 0 }); equal(ym2.month, 0); }); - it('Temporal.MonthDay.from()', () => equal(`${md}`, '1972-06-05[c=zerobased]')); + it('Temporal.MonthDay.from()', () => equal(`${md}`, '1972-06-05[c=zero-based]')); it('Temporal.MonthDay fields', () => { equal(dt.month, 5); equal(dt.day, 5); @@ -135,87 +135,87 @@ describe('Userland calendar', () => { id = `${item}`; // TODO: Use Temporal.parse here to extract the ID from an ISO string } - if (id === 'zerobased') return new ZeroBasedCalendar(); + if (id === 'zero-based') return new ZeroBasedCalendar(); return originalTemporalCalendarFrom.call(this, id); }; }); it('works for Calendar.from(id)', () => { - const tz = Temporal.Calendar.from('zerobased'); + const tz = Temporal.Calendar.from('zero-based'); assert(tz instanceof ZeroBasedCalendar); }); - const iso = '1970-01-01T00:00+00:00[c=zerobased]'; + const iso = '1970-01-01T00:00+00:00[c=zero-based]'; it.skip('works for Calendar.from(ISO string)', () => { const tz = Temporal.Calendar.from(iso); assert(tz instanceof ZeroBasedCalendar); }); it('works for Date.from(iso)', () => { const d = Temporal.Date.from(iso); - equal(`${d}`, '1970-01-01[c=zerobased]'); + equal(`${d}`, '1970-01-01[c=zero-based]'); }); it('works for Date.from(props)', () => { - const d = Temporal.Date.from({ year: 1970, month: 0, day: 1, calendar: 'zerobased' }); - equal(`${d}`, '1970-01-01[c=zerobased]'); + const d = Temporal.Date.from({ year: 1970, month: 0, day: 1, calendar: 'zero-based' }); + equal(`${d}`, '1970-01-01[c=zero-based]'); }); it('works for Date.with', () => { const d1 = Temporal.Date.from('1970-02-01'); - const d2 = d1.with({ month: 0, calendar: 'zerobased' }); - equal(`${d2}`, '1970-01-01[c=zerobased]'); + const d2 = d1.with({ month: 0, calendar: 'zero-based' }); + equal(`${d2}`, '1970-01-01[c=zero-based]'); }); it('works for Date.withCalendar', () => { const d = Temporal.Date.from('1970-01-01'); - assert(d.withCalendar('zerobased').equals(Temporal.Date.from(iso))); + assert(d.withCalendar('zero-based').equals(Temporal.Date.from(iso))); }); it('works for DateTime.from(iso)', () => { const dt = Temporal.DateTime.from(iso); - equal(`${dt}`, '1970-01-01T00:00[c=zerobased]'); + equal(`${dt}`, '1970-01-01T00:00[c=zero-based]'); }); it('works for DateTime.from(props)', () => { - const dt = Temporal.DateTime.from({ year: 1970, month: 0, day: 1, hour: 12, calendar: 'zerobased' }); - equal(`${dt}`, '1970-01-01T12:00[c=zerobased]'); + const dt = Temporal.DateTime.from({ year: 1970, month: 0, day: 1, hour: 12, calendar: 'zero-based' }); + equal(`${dt}`, '1970-01-01T12:00[c=zero-based]'); }); it('works for DateTime.with', () => { const dt1 = Temporal.DateTime.from('1970-02-01T12:00'); - const dt2 = dt1.with({ month: 0, calendar: 'zerobased' }); - equal(`${dt2}`, '1970-01-01T12:00[c=zerobased]'); + const dt2 = dt1.with({ month: 0, calendar: 'zero-based' }); + equal(`${dt2}`, '1970-01-01T12:00[c=zero-based]'); }); it('works for DateTime.withCalendar', () => { const dt = Temporal.DateTime.from('1970-01-01T00:00'); - assert(dt.withCalendar('zerobased').equals(Temporal.DateTime.from(iso))); + assert(dt.withCalendar('zero-based').equals(Temporal.DateTime.from(iso))); }); it('works for YearMonth.from(iso)', () => { const ym = Temporal.YearMonth.from(iso); - equal(`${ym}`, '1970-01-01[c=zerobased]'); + equal(`${ym}`, '1970-01-01[c=zero-based]'); }); it('works for YearMonth.from(props)', () => { - const ym = Temporal.YearMonth.from({ year: 1970, month: 0, calendar: 'zerobased' }); - equal(`${ym}`, '1970-01-01[c=zerobased]'); + const ym = Temporal.YearMonth.from({ year: 1970, month: 0, calendar: 'zero-based' }); + equal(`${ym}`, '1970-01-01[c=zero-based]'); }); it('works for MonthDay.from(iso)', () => { const md = Temporal.MonthDay.from(iso); - equal(`${md}`, '1970-01-01[c=zerobased]'); + equal(`${md}`, '1970-01-01[c=zero-based]'); }); it('works for MonthDay.from(props)', () => { - const md = Temporal.MonthDay.from({ month: 0, day: 1, calendar: 'zerobased' }); - equal(`${md}`, '1972-01-01[c=zerobased]'); + const md = Temporal.MonthDay.from({ month: 0, day: 1, calendar: 'zero-based' }); + equal(`${md}`, '1972-01-01[c=zero-based]'); }); it('works for TimeZone.getDateTimeFor', () => { const tz = Temporal.TimeZone.from('UTC'); const inst = Temporal.Instant.fromEpochSeconds(0); - const dt = tz.getDateTimeFor(inst, 'zerobased'); - equal(dt.calendar.id, 'zerobased'); + const dt = tz.getDateTimeFor(inst, 'zero-based'); + equal(dt.calendar.id, 'zero-based'); }); it('works for Instant.toDateTime', () => { const inst = Temporal.Instant.fromEpochSeconds(0); - const dt = inst.toDateTime('UTC', 'zerobased'); - equal(dt.calendar.id, 'zerobased'); + const dt = inst.toDateTime('UTC', 'zero-based'); + equal(dt.calendar.id, 'zero-based'); }); it('works for Temporal.now.dateTime', () => { - const nowDateTime = Temporal.now.dateTime('zerobased', 'UTC'); - equal(nowDateTime.calendar.id, 'zerobased'); + const nowDateTime = Temporal.now.dateTime('zero-based', 'UTC'); + equal(nowDateTime.calendar.id, 'zero-based'); }); it('works for Temporal.now.date', () => { - const nowDate = Temporal.now.date('zerobased', 'UTC'); - equal(nowDate.calendar.id, 'zerobased'); + const nowDate = Temporal.now.date('zero-based', 'UTC'); + equal(nowDate.calendar.id, 'zero-based'); }); after(() => { Temporal.Calendar.from = originalTemporalCalendarFrom; diff --git a/spec/abstractops.html b/spec/abstractops.html index 4c6064e230..938957a012 100644 --- a/spec/abstractops.html +++ b/spec/abstractops.html @@ -100,14 +100,20 @@

ToTemporalOverflow ( _normalizedOptions_ )

- -

IsValidCalendarName ( _calendar_ )

+ +

IsValidCalendarID ( _id_ )

- The IsValidCalendarName abstract operation verifies that the _calendar_ argument (which must be a String value) represents a valid Unicode Calendar Identifier value, according to BCP47. + The IsValidCalendarID abstract operation verifies that the _id_ argument (which must be a String value) represents a valid Unicode Calendar Identifier value, according to BCP47.

- The abstract operation returns *true* if _calendar_, converted to upper case as described in , is equal to one of the Unicode Calendar Identifier values, according to BCP47, converted to uppercase as described in . It returns *false* otherwise. + The abstract operation returns *true* if _id_ satisfies the syntax of the |CalendarName| production in . + It returns *false* otherwise.

+ +

+ The exact syntax of calendar IDs will depend on ongoing discussions on how to standardize calendar identifiers in RFC 3339. +

+
@@ -465,6 +471,12 @@

ISO 8601 grammar

+ Alpha : one of + `A` `B` `C` `D` `E` `F` `G` `H` `I` `J` `K` `L` `M` + `N` `O` `P` `Q` `R` `S` `T` `U` `V` `W` `X` `Y` `Z` + `a` `b` `c` `d` `e` `f` `g` `h` `i` `j` `k` `l` `m` + `n` `o` `p` `q` `r` `s` `t` `u` `v` `w` `x` `y` `z` + Digit : one of `0` `1` `2` `3` `4` `5` `6` `7` `8` `9` @@ -612,8 +624,16 @@

ISO 8601 grammar

TimeZoneUTCOffset TimeZoneUTCOffset `[` TimeZoneIANAName `]` + CalChar : + Alpha + Digit + + CalendarNameComponent : + CalChar CalChar CalChar CalChar? CalChar? CalChar? CalChar? CalChar? + CalendarName : - > any string for which IsValidCalendarName returns true + CalendarNameComponent + CalendarNameComponent `-` CalendarName Calendar : `[c=` CalendarName `]` diff --git a/spec/calendar.html b/spec/calendar.html index bf56a724bc..07510e064e 100644 --- a/spec/calendar.html +++ b/spec/calendar.html @@ -95,6 +95,8 @@

Temporal.Calendar ( _id_ )

1. If NewTarget is *undefined*, then 1. Throw a *TypeError* exception. + 1. If ! IsValidCalendarID(_id_) is *false*, then + 1. Throw a *RangeError* exception. 1. Let _calendar_ be ? OrdinaryCreateFromConstructor(NewTarget, *"%Temporal.Calendar.prototype%"*, « [[InitializedTemporalCalendar]], [[Identifier]] »). 1. Set _calendar_.[[Identifier]] to _id_. 1. Return _calendar_.