Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for parsing iso-8601 dates #215

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"jest-worker@^28.1.3": "patch:jest-worker@npm%3A28.1.3#./.yarn/patches/jest-worker-npm-28.1.3-5d0ff9006c.patch"
},
"dependencies": {
"@date-fns/tz": "^1.1.2",
"@ethereumjs/tx": "^4.2.0",
"@metamask/superstruct": "^3.1.0",
"@noble/hashes": "^1.3.1",
Expand Down
2 changes: 2 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('index', () => {
"HexAddressStruct",
"HexChecksumAddressStruct",
"HexStruct",
"InvalidIso8601Date",
"JsonRpcErrorStruct",
"JsonRpcFailureStruct",
"JsonRpcIdStruct",
Expand Down Expand Up @@ -131,6 +132,7 @@ describe('index', () => {
"object",
"parseCaipAccountId",
"parseCaipChainId",
"parseIso8601DateTime",
"remove0x",
"satisfiesVersionRange",
"signedBigIntToBytes",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './promise';
export * from './time';
export * from './transaction-types';
export * from './versions';
export * from './iso8601-date';
97 changes: 97 additions & 0 deletions src/iso8601-date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { TZDate } from '@date-fns/tz';
import assert from 'assert';

import { InvalidIso8601Date, parseIso8601DateTime } from './iso8601-date';

describe('parseDateTime', () => {
it.each([
['2020', new Date(2020, 0)],
['2021-02', new Date(2021, 1)],
['2022-10', new Date(2022, 9)],
['2023-11-02', new Date(2023, 10, 2)],
['2024-12T01', new Date(2024, 11, 1, 1)],
['2025-01T02:01', new Date(2025, 0, 1, 2, 1)],
['2026-02-02T03', new Date(2026, 1, 2, 3)],
['2027-03-03T04:02', new Date(2027, 2, 3, 4, 2)],
['2027-03-03T04:02:01', new Date(2027, 2, 3, 4, 2, 1)],
['20280404', new Date(2028, 3, 4)],
['20290505T05', new Date(2029, 4, 5, 5)],
['20300606T0603', new Date(2030, 5, 6, 6, 3)],
['20310707T070402', new Date(2031, 6, 7, 7, 4, 2)],
])('parses %s local-time correctly', (testIso, expectedDate) => {
const result = parseIso8601DateTime(testIso);

expect(result).toBeInstanceOf(Date);
expect(result.toISOString()).toStrictEqual(expectedDate.toISOString());
});

it.each([
['2023-04-04T04Z', new TZDate(2023, 3, 4, 4, '+00:00')],
['2023-04-04T04-01', new TZDate(2023, 3, 4, 4, '-01:00')],
['2023-04-04T04+02', new TZDate(2023, 3, 4, 4, '+02:00')],
['2023-04-04T04-01:01', new TZDate(2023, 3, 4, 4, '-01:01')],
['2023-04-04T04+02:02', new TZDate(2023, 3, 4, 4, '+02:02')],

['2023-04-04T04:04Z', new TZDate(2023, 3, 4, 4, 4, '+00:00')],
['2023-04-04T04:04-01', new TZDate(2023, 3, 4, 4, 4, '-01:00')],
['2023-04-04T04:04+02', new TZDate(2023, 3, 4, 4, 4, '+02:00')],
['2023-04-04T04:04-01:01', new TZDate(2023, 3, 4, 4, 4, '-01:01')],
['2023-04-04T04:04+02:02', new TZDate(2023, 3, 4, 4, 4, '+02:02')],

['2023-04-04T04:04:04Z', new TZDate(2023, 3, 4, 4, 4, 4, '+00:00')],
['2023-04-04T04:04:04-01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:00')],
['2023-04-04T04:04:04+02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:00')],
['2023-04-04T04:04:04-01:01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:01')],
['2023-04-04T04:04:04+02:02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:02')],
])('parses %s with time-zone correctly', (testIso, expectedDate) => {
const result = parseIso8601DateTime(testIso);

assert(result instanceof TZDate);
expect(result.timeZone).toStrictEqual(expectedDate.timeZone);
expect(result.toISOString()).toStrictEqual(expectedDate.toISOString());
});

it.each([
'',
'0',
'00',
'000',
'0000a',
'2020-0',
'2020-00',
'2020-01a',
'202001',
'2020-01-00',
'2020-01-01a',
'2020-0101',
'202001-01',
'2020-01-01T',
'2020-01-01T0000',
'2020-01-01T00:00a',
'2020-01-01T00:0000',
'00:00',
'2020:01',
'2020-01:01',
'2020-01-01T00:00A',
'2020-01-01T00:00Za',
'2020-01-01T00:00+a',
'2020-01-01T00:00-a',
'2020-01-01T00:00+0',
'2020-01-01T00:00-0',
'2020-01-01T00:00+00a',
'2020-01-01T00:00+00:0',
'2020-01-01T00:00-00:0',
'2020-01-01T00:00+00:00a',
'2020-01-01T00:00-00:00a',
'2020-',
'2020-01-01T24',
'2020-01-01T23:60',
'2020-01-01T23:59:60',
'2020-01-01T23:59:59-13',
'2020-01-01T23:59:59+13',
'2020-01-01T23:59:59-11:60',
'2020-01-01T23:59:59a',
])('throws on invalid date time "%s"', (testIso) => {
expect(() => parseIso8601DateTime(testIso)).toThrow(InvalidIso8601Date);
});
});
256 changes: 256 additions & 0 deletions src/iso8601-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { TZDate } from '@date-fns/tz/date';

import { assert } from './assert';

enum ParseDateState {
Year = 1,
Month = 2,
CalendarDate = 3,
Hour = 4,
Minute = 5,
Second = 6,
Timezone = 7,
TimezoneHour = 8,
TimezoneMinute = 9,
End = 0,
}
const END = Symbol('END');

const RE_YEAR = /[0-9]{4}/u;
const RE_MONTH = /(?:0[1-9])|(?:1[0-2])/u;
const RE_DATE = /(?:[1-9])|(?:[1-2][0-9])|(?:3[0-1])/u;
const RE_HOUR = /(?:[0-1][0-9])|(?:2[0-3])/u;
const RE_MINUTE_SECOND = /[0-5][0-9]/u;
const RE_Z_HOUR = /(?:0[0-9])|(?:1[0-2])/u;

export const InvalidIso8601Date = new Error('Invalid ISO-8601 date');

/**
* Parses ISO-8601 date time string.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Parses ISO-8601 date time string.
* Parses ISO-8601 extended format date time string.

*
* @throws {InvalidIso8601Date} Is the input value is not correct.
* @param value - An ISO-8601 formatted string.
* @returns A date if value is in local-time, a TZDate if timezone data provided.
*/
export function parseIso8601DateTime(value: string): Date | TZDate {
let at = 0;
let hasSeparators: boolean | null = null;
let state: ParseDateState = ParseDateState.Year;

const consume = () => {
if (at >= value.length) {
throw InvalidIso8601Date;
}
const char = value[at] as string;
at += 1;
return char;
};
const peek = () => {
Comment on lines +47 to +48

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
};
const peek = () => {
};
const peek = () => {

if (at >= value.length) {
return END;
}
return value[at] as string;
};
const skip = (count = 1) => {
Comment on lines +53 to +54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
};
const skip = (count = 1) => {
};
const skip = (count = 1) => {

assert(at + count <= value.length, 'Invalid ISO-8601 parser state');
at += count;
};
const consumeSeparator = (sep: '-' | ':') => {
Comment on lines +57 to +58

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
};
const consumeSeparator = (sep: '-' | ':') => {
};
const consumeSeparator = (sep: '-' | ':') => {

const next = peek();
assert(next !== END, 'Invalid ISO-8601 parser state');
if (next === '-' || next === ':') {
if (hasSeparators === false || next !== sep) {
throw InvalidIso8601Date;
}
hasSeparators = true;
skip();
} else {
if (hasSeparators === true) {
throw InvalidIso8601Date;
}
hasSeparators = false;
}
};

let year: undefined | string;
let month: undefined | string;
let date: undefined | string;
let hours: undefined | string;
let minutes: undefined | string;
let seconds: undefined | string;
let timezone: null | string = null; // null, "Z", "+", "-"
let offsetHours: undefined | string;
let offsetMinutes: undefined | string;

while (state !== ParseDateState.End) {
switch (state) {
case ParseDateState.Year:
year = '';
for (let i = 0; i < 4; i++) {
year += consume();
}
if (!RE_YEAR.test(year)) {
throw InvalidIso8601Date;
}
if (peek() === END) {
state = ParseDateState.End;
} else {
consumeSeparator('-');
state = ParseDateState.Month;
}
break;
case ParseDateState.Month:
month = consume() + consume();
if (!RE_MONTH.test(month)) {
throw InvalidIso8601Date;
}

// YYYYMM is not a valid ISO-8601
// it requires a separator: YYYY-MM
if (hasSeparators === false && (peek() === END || peek() === 'T')) {
throw InvalidIso8601Date;
} else if (peek() === END) {
state = ParseDateState.End;
} else if (peek() === 'T') {
state = ParseDateState.Hour;
} else {
consumeSeparator('-');
state = ParseDateState.CalendarDate;
}
break;
case ParseDateState.CalendarDate:
date = consume() + consume();
if (!RE_DATE.test(date)) {
throw InvalidIso8601Date;
}

if (peek() === END) {
state = ParseDateState.End;
} else {
state = ParseDateState.Hour;
}
break;
case ParseDateState.Hour:
if (consume() !== 'T') {
throw InvalidIso8601Date;
}

hours = consume() + consume();
if (!RE_HOUR.test(hours)) {
throw InvalidIso8601Date;
}

if (peek() === END) {
state = ParseDateState.End;
} else if (['Z', '-', '+'].includes(peek() as string)) {
state = ParseDateState.Timezone;
} else {
consumeSeparator(':');
state = ParseDateState.Minute;
}
break;
case ParseDateState.Minute:
minutes = consume() + consume();
if (!RE_MINUTE_SECOND.test(minutes)) {
throw InvalidIso8601Date;
}

if (peek() === END) {
state = ParseDateState.End;
} else if (['Z', '-', '+'].includes(peek() as string)) {
state = ParseDateState.Timezone;
} else {
consumeSeparator(':');
state = ParseDateState.Second;
}
break;
case ParseDateState.Second:
seconds = consume() + consume();
if (!RE_MINUTE_SECOND.test(seconds)) {
throw InvalidIso8601Date;
}

if (peek() === END) {
state = ParseDateState.End;
} else {
state = ParseDateState.Timezone;
}
break;
case ParseDateState.Timezone:
timezone = consume();

if (timezone === 'Z') {
if (peek() !== END) {
throw InvalidIso8601Date;
}
state = ParseDateState.End;
} else if (['-', '+'].includes(timezone)) {
state = ParseDateState.TimezoneHour;
} else {
throw InvalidIso8601Date;
}
Comment on lines +179 to +191

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic should probably be updated in accordance with MetaMask/SIPs#152

break;
case ParseDateState.TimezoneHour:
offsetHours = consume() + consume();
if (!RE_Z_HOUR.test(offsetHours)) {
throw InvalidIso8601Date;
}

if (peek() === END) {
state = ParseDateState.End;
} else {
consumeSeparator(':');
state = ParseDateState.TimezoneMinute;
}
break;
case ParseDateState.TimezoneMinute:
offsetMinutes = consume() + consume();
if (!RE_MINUTE_SECOND.test(offsetMinutes)) {
throw InvalidIso8601Date;
}

if (peek() === END) {
state = ParseDateState.End;
} else {
// Garbage at the end
throw InvalidIso8601Date;
}
break;
/* istanbul ignore next */
default:
assert(false, 'Invalid ISO-8601 parser state');
}
}

assert(year !== undefined, 'Invalid ISO-8601 parser state');
month = month ?? '01';
date = date ?? '01';
hours = hours ?? '00';
minutes = minutes ?? '00';
seconds = seconds ?? '00';
offsetHours = offsetHours ?? '00';
offsetMinutes = offsetMinutes ?? '00';
Comment on lines +225 to +232

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind leaving a comment about how this is accounting for partial dates as that is something that ISO-8601 allows?


if (timezone !== null) {
if (timezone === 'Z') {
timezone = '+';
}
return new TZDate(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(date, 10),
parseInt(hours, 10),
parseInt(minutes, 10),
parseInt(seconds, 10),
`${timezone}${offsetHours}:${offsetMinutes}`,
);
}
return new Date(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(date, 10),
parseInt(hours, 10),
parseInt(minutes, 10),
parseInt(seconds, 10),
);
}
2 changes: 2 additions & 0 deletions src/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('node', () => {
"HexAddressStruct",
"HexChecksumAddressStruct",
"HexStruct",
"InvalidIso8601Date",
"JsonRpcErrorStruct",
"JsonRpcFailureStruct",
"JsonRpcIdStruct",
Expand Down Expand Up @@ -136,6 +137,7 @@ describe('node', () => {
"object",
"parseCaipAccountId",
"parseCaipChainId",
"parseIso8601DateTime",
"readFile",
"readJsonFile",
"remove0x",
Expand Down
Loading
Loading