Skip to content

Commit

Permalink
Define valid calendar IDs
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ptomato committed Oct 16, 2020
1 parent da7ae85 commit e5ad6bd
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 45 deletions.
2 changes: 2 additions & 0 deletions docs/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 not be any of the built-in calendar identifiers, and must consist of one or more components of between 2 and 8 ASCII alphanumeric characters each, separated by dashes, as described in [BCP 47](https://tools.ietf.org/html/bcp47#section-2.1).

### Protocol

It's also possible for a plain object to be a custom calendar, without subclassing.
Expand Down
4 changes: 4 additions & 0 deletions polyfill/lib/calendar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const calComponent = /[A-Za-z0-9]{2,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})?$`,
Expand Down
2 changes: 1 addition & 1 deletion polyfill/test/instant.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
29 changes: 28 additions & 1 deletion polyfill/test/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -534,6 +534,33 @@ describe('fromString regex', () => {
}
}
});

describe('calendar ID', () => {
function makeCustomCalendar(id) {
return class extends Temporal.Calendar {
constructor() {
super(id);
}
};
}
describe('valid', () => {
['aa', 'aa-aa', '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', 'foo_', 'foo.', 'ninechars', 'ninechars-ninechars'].forEach((id) => {
it(id, () => {
const Custom = makeCustomCalendar(id);
throws(() => new Custom(), RangeError);
});
});
});
});
});

import { normalize } from 'path';
Expand Down
74 changes: 37 additions & 37 deletions polyfill/test/usercalendar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 20 additions & 5 deletions spec/abstractops.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,14 @@ <h1>ToTemporalOverflow ( _normalizedOptions_ )</h1>
</emu-alg>
</emu-clause>

<emu-clause id="sec-temporal-isvalidcalendarname" aoid="IsValidCalendarName">
<h1>IsValidCalendarName ( _calendar_ )</h1>
<emu-clause id="sec-temporal-isvalidcalendarid" aoid="IsValidCalendarID">
<h1>IsValidCalendarID ( _id_ )</h1>
<p>
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.
</p>
<p>
The abstract operation returns *true* if _calendar_, converted to upper case as described in <emu-xref href="#sec-case-sensitivity-and-case-mapping"></emu-xref>, is equal to one of the Unicode Calendar Identifier values, according to BCP47, converted to uppercase as described in <emu-xref href="#sec-case-sensitivity-and-case-mapping"></emu-xref>. It returns *false* otherwise.
The abstract operation returns *true* if _id_ satisfies the syntax of the |CalendarName| production in <emu-xref href="#sec-temporal-iso8601grammar"></emu-xref>.
It returns *false* otherwise.
</p>
</emu-clause>

Expand Down Expand Up @@ -465,6 +466,12 @@ <h1>ISO 8601 grammar</h1>
</ul>

<emu-grammar type="definition">
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`

Expand Down Expand Up @@ -612,8 +619,16 @@ <h1>ISO 8601 grammar</h1>
TimeZoneUTCOffset
TimeZoneUTCOffset `[` TimeZoneIANAName `]`

CalChar :
Alpha
Digit

CalendarNameComponent :
CalChar CalChar CalChar? CalChar? CalChar? CalChar? CalChar? CalChar?

CalendarName :
&gt; any string for which IsValidCalendarName returns true
CalendarNameComponent
CalendarNameComponent `-` CalendarName

Calendar :
`[c=` CalendarName `]`
Expand Down
2 changes: 2 additions & 0 deletions spec/calendar.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ <h1>Temporal.Calendar ( _id_ )</h1>
<emu-alg>
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_.
Expand Down

0 comments on commit e5ad6bd

Please sign in to comment.