refactor(datetime): cleanup DateTimeFormatter and parse() function (#5649)

* initial commit

* update

* fix

* update

---------

Co-authored-by: Asher Gomez <ashersaupingomez@gmail.com>
This commit is contained in:
Tim Reichen 2024-08-08 18:31:48 +02:00 committed by GitHub
parent 0c64f32cc3
commit c7a39f0752
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 105 deletions

View File

@ -36,90 +36,89 @@ type FormatPart = {
value: string | number;
hour12?: boolean;
};
type Format = FormatPart[];
const QUOTED_LITERAL_REGEXP = /^(')(?<value>\\.|[^\']*)\1/;
const LITERAL_REGEXP = /^(?<value>.+?\s*)/;
const SYMBOL_REGEXP = /^(?<symbol>([a-zA-Z])\2*)/;
// according to unicode symbols (http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
function formatToParts(format: string) {
const tokens: Format = [];
function formatToFormatParts(format: string) {
const formatParts: FormatPart[] = [];
let index = 0;
while (index < format.length) {
const substring = format.slice(index);
const symbol = SYMBOL_REGEXP.exec(substring)?.groups?.symbol;
switch (symbol) {
case "yyyy":
tokens.push({ type: "year", value: "numeric" });
formatParts.push({ type: "year", value: "numeric" });
index += symbol.length;
continue;
case "yy":
tokens.push({ type: "year", value: "2-digit" });
formatParts.push({ type: "year", value: "2-digit" });
index += symbol.length;
continue;
case "MM":
tokens.push({ type: "month", value: "2-digit" });
formatParts.push({ type: "month", value: "2-digit" });
index += symbol.length;
continue;
case "M":
tokens.push({ type: "month", value: "numeric" });
formatParts.push({ type: "month", value: "numeric" });
index += symbol.length;
continue;
case "dd":
tokens.push({ type: "day", value: "2-digit" });
formatParts.push({ type: "day", value: "2-digit" });
index += symbol.length;
continue;
case "d":
tokens.push({ type: "day", value: "numeric" });
formatParts.push({ type: "day", value: "numeric" });
index += symbol.length;
continue;
case "HH":
tokens.push({ type: "hour", value: "2-digit" });
formatParts.push({ type: "hour", value: "2-digit" });
index += symbol.length;
continue;
case "H":
tokens.push({ type: "hour", value: "numeric" });
formatParts.push({ type: "hour", value: "numeric" });
index += symbol.length;
continue;
case "hh":
tokens.push({ type: "hour", value: "2-digit", hour12: true });
formatParts.push({ type: "hour", value: "2-digit", hour12: true });
index += symbol.length;
continue;
case "h":
tokens.push({ type: "hour", value: "numeric", hour12: true });
formatParts.push({ type: "hour", value: "numeric", hour12: true });
index += symbol.length;
continue;
case "mm":
tokens.push({ type: "minute", value: "2-digit" });
formatParts.push({ type: "minute", value: "2-digit" });
index += symbol.length;
continue;
case "m":
tokens.push({ type: "minute", value: "numeric" });
formatParts.push({ type: "minute", value: "numeric" });
index += symbol.length;
continue;
case "ss":
tokens.push({ type: "second", value: "2-digit" });
formatParts.push({ type: "second", value: "2-digit" });
index += symbol.length;
continue;
case "s":
tokens.push({ type: "second", value: "numeric" });
formatParts.push({ type: "second", value: "numeric" });
index += symbol.length;
continue;
case "SSS":
tokens.push({ type: "fractionalSecond", value: 3 });
formatParts.push({ type: "fractionalSecond", value: 3 });
index += symbol.length;
continue;
case "SS":
tokens.push({ type: "fractionalSecond", value: 2 });
formatParts.push({ type: "fractionalSecond", value: 2 });
index += symbol.length;
continue;
case "S":
tokens.push({ type: "fractionalSecond", value: 1 });
formatParts.push({ type: "fractionalSecond", value: 1 });
index += symbol.length;
continue;
case "a":
tokens.push({ type: "dayPeriod", value: 1 });
formatParts.push({ type: "dayPeriod", value: 1 });
index += symbol.length;
continue;
}
@ -127,25 +126,48 @@ function formatToParts(format: string) {
const quotedLiteralMatch = QUOTED_LITERAL_REGEXP.exec(substring);
if (quotedLiteralMatch) {
const value = quotedLiteralMatch.groups!.value as string;
tokens.push({ type: "literal", value });
formatParts.push({ type: "literal", value });
index += quotedLiteralMatch[0].length;
continue;
}
const literalGroups = LITERAL_REGEXP.exec(substring)!.groups!;
const value = literalGroups.value as string;
tokens.push({ type: "literal", value });
formatParts.push({ type: "literal", value });
index += value.length;
}
return tokens;
return formatParts;
}
function sortDateTimeFormatParts(
parts: DateTimeFormatPart[],
): DateTimeFormatPart[] {
let result: DateTimeFormatPart[] = [];
const typeArray = [
"year",
"month",
"day",
"hour",
"minute",
"second",
"fractionalSecond",
];
for (const type of typeArray) {
const current = parts.findIndex((el) => el.type === type);
if (current !== -1) {
result = result.concat(parts.splice(current, 1));
}
}
result = result.concat(parts);
return result;
}
export class DateTimeFormatter {
#format: Format;
#formatParts: FormatPart[];
constructor(formatString: string) {
this.#format = formatToParts(formatString);
this.#formatParts = formatToFormatParts(formatString);
}
format(date: Date, options: Options = {}): string {
@ -153,13 +175,13 @@ export class DateTimeFormatter {
const utc = options.timeZone === "UTC";
for (const token of this.#format) {
const type = token.type;
for (const part of this.#formatParts) {
const type = part.type;
switch (type) {
case "year": {
const value = utc ? date.getUTCFullYear() : date.getFullYear();
switch (token.value) {
switch (part.value) {
case "numeric": {
string += value;
break;
@ -170,14 +192,14 @@ export class DateTimeFormatter {
}
default:
throw Error(
`FormatterError: value "${token.value}" is not supported`,
`FormatterError: value "${part.value}" is not supported`,
);
}
break;
}
case "month": {
const value = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
switch (token.value) {
switch (part.value) {
case "numeric": {
string += value;
break;
@ -188,14 +210,14 @@ export class DateTimeFormatter {
}
default:
throw Error(
`FormatterError: value "${token.value}" is not supported`,
`FormatterError: value "${part.value}" is not supported`,
);
}
break;
}
case "day": {
const value = utc ? date.getUTCDate() : date.getDate();
switch (token.value) {
switch (part.value) {
case "numeric": {
string += value;
break;
@ -206,18 +228,18 @@ export class DateTimeFormatter {
}
default:
throw Error(
`FormatterError: value "${token.value}" is not supported`,
`FormatterError: value "${part.value}" is not supported`,
);
}
break;
}
case "hour": {
let value = utc ? date.getUTCHours() : date.getHours();
if (token.hour12) {
if (part.hour12) {
if (value === 0) value = 12;
else if (value > 12) value -= 12;
}
switch (token.value) {
switch (part.value) {
case "numeric": {
string += value;
break;
@ -228,14 +250,14 @@ export class DateTimeFormatter {
}
default:
throw Error(
`FormatterError: value "${token.value}" is not supported`,
`FormatterError: value "${part.value}" is not supported`,
);
}
break;
}
case "minute": {
const value = utc ? date.getUTCMinutes() : date.getMinutes();
switch (token.value) {
switch (part.value) {
case "numeric": {
string += value;
break;
@ -246,14 +268,14 @@ export class DateTimeFormatter {
}
default:
throw Error(
`FormatterError: value "${token.value}" is not supported`,
`FormatterError: value "${part.value}" is not supported`,
);
}
break;
}
case "second": {
const value = utc ? date.getUTCSeconds() : date.getSeconds();
switch (token.value) {
switch (part.value) {
case "numeric": {
string += value;
break;
@ -264,7 +286,7 @@ export class DateTimeFormatter {
}
default:
throw Error(
`FormatterError: value "${token.value}" is not supported`,
`FormatterError: value "${part.value}" is not supported`,
);
}
break;
@ -273,12 +295,12 @@ export class DateTimeFormatter {
const value = utc
? date.getUTCMilliseconds()
: date.getMilliseconds();
string += digits(value, Number(token.value));
string += digits(value, Number(part.value));
break;
}
// FIXME(bartlomieju)
case "timeZoneName": {
// string += utc ? "Z" : token.value
// string += utc ? "Z" : part.value
break;
}
case "dayPeriod": {
@ -286,28 +308,28 @@ export class DateTimeFormatter {
break;
}
case "literal": {
string += token.value;
string += part.value;
break;
}
default:
throw Error(`FormatterError: { ${token.type} ${token.value} }`);
throw Error(`FormatterError: { ${part.type} ${part.value} }`);
}
}
return string;
}
parseToParts(string: string): DateTimeFormatPart[] {
formatToParts(string: string): DateTimeFormatPart[] {
const parts: DateTimeFormatPart[] = [];
for (const token of this.#format) {
const type = token.type;
for (const part of this.#formatParts) {
const type = part.type;
let value = "";
switch (token.type) {
switch (part.type) {
case "year": {
switch (token.value) {
switch (part.value) {
case "numeric": {
value = /^\d{1,4}/.exec(string)?.[0] as string;
break;
@ -318,13 +340,13 @@ export class DateTimeFormatter {
}
default:
throw Error(
`ParserError: value "${token.value}" is not supported`,
`ParserError: value "${part.value}" is not supported`,
);
}
break;
}
case "month": {
switch (token.value) {
switch (part.value) {
case "numeric": {
value = /^\d{1,2}/.exec(string)?.[0] as string;
break;
@ -347,13 +369,13 @@ export class DateTimeFormatter {
}
default:
throw Error(
`ParserError: value "${token.value}" is not supported`,
`ParserError: value "${part.value}" is not supported`,
);
}
break;
}
case "day": {
switch (token.value) {
switch (part.value) {
case "numeric": {
value = /^\d{1,2}/.exec(string)?.[0] as string;
break;
@ -364,16 +386,16 @@ export class DateTimeFormatter {
}
default:
throw Error(
`ParserError: value "${token.value}" is not supported`,
`ParserError: value "${part.value}" is not supported`,
);
}
break;
}
case "hour": {
switch (token.value) {
switch (part.value) {
case "numeric": {
value = /^\d{1,2}/.exec(string)?.[0] as string;
if (token.hour12 && parseInt(value) > 12) {
if (part.hour12 && parseInt(value) > 12) {
console.error(
`Trying to parse hour greater than 12. Use 'H' instead of 'h'.`,
);
@ -382,7 +404,7 @@ export class DateTimeFormatter {
}
case "2-digit": {
value = /^\d{2}/.exec(string)?.[0] as string;
if (token.hour12 && parseInt(value) > 12) {
if (part.hour12 && parseInt(value) > 12) {
console.error(
`Trying to parse hour greater than 12. Use 'HH' instead of 'hh'.`,
);
@ -391,13 +413,13 @@ export class DateTimeFormatter {
}
default:
throw Error(
`ParserError: value "${token.value}" is not supported`,
`ParserError: value "${part.value}" is not supported`,
);
}
break;
}
case "minute": {
switch (token.value) {
switch (part.value) {
case "numeric": {
value = /^\d{1,2}/.exec(string)?.[0] as string;
break;
@ -408,13 +430,13 @@ export class DateTimeFormatter {
}
default:
throw Error(
`ParserError: value "${token.value}" is not supported`,
`ParserError: value "${part.value}" is not supported`,
);
}
break;
}
case "second": {
switch (token.value) {
switch (part.value) {
case "numeric": {
value = /^\d{1,2}/.exec(string)?.[0] as string;
break;
@ -425,18 +447,18 @@ export class DateTimeFormatter {
}
default:
throw Error(
`ParserError: value "${token.value}" is not supported`,
`ParserError: value "${part.value}" is not supported`,
);
}
break;
}
case "fractionalSecond": {
value = new RegExp(`^\\d{${token.value}}`).exec(string)
value = new RegExp(`^\\d{${part.value}}`).exec(string)
?.[0] as string;
break;
}
case "timeZoneName": {
value = token.value as string;
value = part.value as string;
break;
}
case "dayPeriod": {
@ -444,22 +466,22 @@ export class DateTimeFormatter {
break;
}
case "literal": {
if (!string.startsWith(token.value as string)) {
if (!string.startsWith(part.value as string)) {
throw Error(
`Literal "${token.value}" not found "${string.slice(0, 25)}"`,
`Literal "${part.value}" not found "${string.slice(0, 25)}"`,
);
}
value = token.value as string;
value = part.value as string;
break;
}
default:
throw Error(`${token.type} ${token.value}`);
throw Error(`${part.type} ${part.value}`);
}
if (!value) {
throw Error(
`value not valid for token { ${type} ${value} } ${
`value not valid for part { ${type} ${value} } ${
string.slice(
0,
25,
@ -481,29 +503,9 @@ export class DateTimeFormatter {
return parts;
}
/** sort & filter dateTimeFormatPart */
sortDateTimeFormatPart(parts: DateTimeFormatPart[]): DateTimeFormatPart[] {
let result: DateTimeFormatPart[] = [];
const typeArray = [
"year",
"month",
"day",
"hour",
"minute",
"second",
"fractionalSecond",
];
for (const type of typeArray) {
const current = parts.findIndex((el) => el.type === type);
if (current !== -1) {
result = result.concat(parts.splice(current, 1));
}
}
result = result.concat(parts);
return result;
}
partsToDate(parts: DateTimeFormatPart[]): Date {
parts = sortDateTimeFormatParts(parts);
const date = new Date();
const utc = parts.find(
(part) => part.type === "timeZoneName" && part.value === "UTC",
@ -565,8 +567,7 @@ export class DateTimeFormatter {
}
parse(string: string): Date {
const parts = this.parseToParts(string);
const sortParts = this.sortDateTimeFormatPart(parts);
return this.partsToDate(sortParts);
const parts = this.formatToParts(string);
return this.partsToDate(parts);
}
}

View File

@ -36,10 +36,10 @@ Deno.test("dateTimeFormatter.parse()", () => {
assertEquals(formatter.parse("2020-01-01"), new Date(2020, 0, 1));
});
Deno.test("dateTimeFormatter.parseToParts()", () => {
Deno.test("dateTimeFormatter.formatToParts()", () => {
const format = "yyyy-MM-dd";
const formatter = new DateTimeFormatter(format);
assertEquals(formatter.parseToParts("2020-01-01"), [
assertEquals(formatter.formatToParts("2020-01-01"), [
{ type: "year", value: "2020" },
{ type: "literal", value: "-" },
{ type: "month", value: "01" },
@ -48,21 +48,21 @@ Deno.test("dateTimeFormatter.parseToParts()", () => {
]);
});
Deno.test("dateTimeFormatter.parseToParts() throws on an empty string", () => {
Deno.test("dateTimeFormatter.formatToParts() throws on an empty string", () => {
const format = "yyyy-MM-dd";
const formatter = new DateTimeFormatter(format);
assertThrows(
() => formatter.parseToParts(""),
() => formatter.formatToParts(""),
Error,
"value not valid for token",
"value not valid for part",
);
});
Deno.test("dateTimeFormatter.parseToParts() throws on a string which exceeds the format", () => {
Deno.test("dateTimeFormatter.formatToParts() throws on a string which exceeds the format", () => {
const format = "yyyy-MM-dd";
const formatter = new DateTimeFormatter(format);
assertThrows(
() => formatter.parseToParts("2020-01-01T00:00:00.000Z"),
() => formatter.formatToParts("2020-01-01T00:00:00.000Z"),
Error,
"datetime string was not fully parsed!",
);

View File

@ -46,7 +46,5 @@ import { DateTimeFormatter } from "./_date_time_formatter.ts";
*/
export function parse(dateString: string, formatString: string): Date {
const formatter = new DateTimeFormatter(formatString);
const parts = formatter.parseToParts(dateString);
const sortParts = formatter.sortDateTimeFormatPart(parts);
return formatter.partsToDate(sortParts);
return formatter.parse(dateString);
}