summaryrefslogtreecommitdiff
path: root/std/datetime/formatter.ts
diff options
context:
space:
mode:
authorTim Reichen <timreichen@users.noreply.github.com>2020-08-15 16:37:17 +0200
committerGitHub <noreply@github.com>2020-08-15 10:37:17 -0400
commit684eddcc6bf0c1446c9aba0cdf001c661c19ab24 (patch)
tree134f5f1609cf9f35295f841e27ba26d64c720795 /std/datetime/formatter.ts
parentb684df784ef0ecbdf8bc3b6015177b6829420f86 (diff)
feat(std/datetime): generalise parser, add formatter (#6619)
Diffstat (limited to 'std/datetime/formatter.ts')
-rw-r--r--std/datetime/formatter.ts534
1 files changed, 534 insertions, 0 deletions
diff --git a/std/datetime/formatter.ts b/std/datetime/formatter.ts
new file mode 100644
index 000000000..0e872cb40
--- /dev/null
+++ b/std/datetime/formatter.ts
@@ -0,0 +1,534 @@
+import {
+ CallbackResult,
+ Rule,
+ TestFunction,
+ TestResult,
+ Tokenizer,
+} from "./tokenizer.ts";
+
+function digits(value: string | number, count = 2): string {
+ return String(value).padStart(count, "0");
+}
+
+// as declared as in namespace Intl
+type DateTimeFormatPartTypes =
+ | "day"
+ | "dayPeriod"
+ // | "era"
+ | "hour"
+ | "literal"
+ | "minute"
+ | "month"
+ | "second"
+ | "timeZoneName"
+ // | "weekday"
+ | "year"
+ | "fractionalSecond";
+
+interface DateTimeFormatPart {
+ type: DateTimeFormatPartTypes;
+ value: string;
+}
+
+type TimeZone = "UTC";
+
+interface Options {
+ timeZone?: TimeZone;
+}
+
+function createLiteralTestFunction(value: string): TestFunction {
+ return (string: string): TestResult => {
+ return string.startsWith(value)
+ ? { value, length: value.length }
+ : undefined;
+ };
+}
+
+function createMatchTestFunction(match: RegExp): TestFunction {
+ return (string: string): TestResult => {
+ const result = match.exec(string);
+ if (result) return { value: result, length: result[0].length };
+ };
+}
+
+// according to unicode symbols (http://userguide.icu-project.org/formatparse/datetime)
+const defaultRules = [
+ {
+ test: createLiteralTestFunction("yyyy"),
+ fn: (): CallbackResult => ({ type: "year", value: "numeric" }),
+ },
+ {
+ test: createLiteralTestFunction("yy"),
+ fn: (): CallbackResult => ({ type: "year", value: "2-digit" }),
+ },
+
+ {
+ test: createLiteralTestFunction("MM"),
+ fn: (): CallbackResult => ({ type: "month", value: "2-digit" }),
+ },
+ {
+ test: createLiteralTestFunction("M"),
+ fn: (): CallbackResult => ({ type: "month", value: "numeric" }),
+ },
+ {
+ test: createLiteralTestFunction("dd"),
+ fn: (): CallbackResult => ({ type: "day", value: "2-digit" }),
+ },
+ {
+ test: createLiteralTestFunction("d"),
+ fn: (): CallbackResult => ({ type: "day", value: "numeric" }),
+ },
+
+ {
+ test: createLiteralTestFunction("hh"),
+ fn: (): CallbackResult => ({ type: "hour", value: "2-digit" }),
+ },
+ {
+ test: createLiteralTestFunction("h"),
+ fn: (): CallbackResult => ({ type: "hour", value: "numeric" }),
+ },
+ {
+ test: createLiteralTestFunction("mm"),
+ fn: (): CallbackResult => ({ type: "minute", value: "2-digit" }),
+ },
+ {
+ test: createLiteralTestFunction("m"),
+ fn: (): CallbackResult => ({ type: "minute", value: "numeric" }),
+ },
+ {
+ test: createLiteralTestFunction("ss"),
+ fn: (): CallbackResult => ({ type: "second", value: "2-digit" }),
+ },
+ {
+ test: createLiteralTestFunction("s"),
+ fn: (): CallbackResult => ({ type: "second", value: "numeric" }),
+ },
+ {
+ test: createLiteralTestFunction("SSS"),
+ fn: (): CallbackResult => ({ type: "fractionalSecond", value: 3 }),
+ },
+ {
+ test: createLiteralTestFunction("SS"),
+ fn: (): CallbackResult => ({ type: "fractionalSecond", value: 2 }),
+ },
+ {
+ test: createLiteralTestFunction("S"),
+ fn: (): CallbackResult => ({ type: "fractionalSecond", value: 1 }),
+ },
+
+ {
+ test: createLiteralTestFunction("a"),
+ fn: (value: unknown): CallbackResult => ({
+ type: "dayPeriod",
+ value: value as string,
+ }),
+ },
+
+ // quoted literal
+ {
+ test: createMatchTestFunction(/^(')(?<value>\\.|[^\']*)\1/),
+ fn: (match: unknown): CallbackResult => ({
+ type: "literal",
+ value: (match as RegExpExecArray).groups!.value as string,
+ }),
+ },
+ // literal
+ {
+ test: createMatchTestFunction(/^.+?\s*/),
+ fn: (match: unknown): CallbackResult => ({
+ type: "literal",
+ value: (match as RegExpExecArray)[0],
+ }),
+ },
+];
+
+type FormatPart = { type: DateTimeFormatPartTypes; value: string | number };
+type Format = FormatPart[];
+
+export class DateTimeFormatter {
+ #format: Format;
+
+ constructor(formatString: string, rules: Rule[] = defaultRules) {
+ const tokenizer = new Tokenizer(rules);
+ this.#format = tokenizer.tokenize(formatString, ({ type, value }) => ({
+ type,
+ value,
+ })) as Format;
+ }
+
+ format(date: Date, options: Options = {}): string {
+ let string = "";
+
+ const utc = options.timeZone === "UTC";
+ const hour12 = this.#format.find(
+ (token: FormatPart) => token.type === "dayPeriod",
+ );
+
+ for (const token of this.#format) {
+ const type = token.type;
+
+ switch (type) {
+ case "year": {
+ const value = utc ? date.getUTCFullYear() : date.getFullYear();
+ switch (token.value) {
+ case "numeric": {
+ string += value;
+ break;
+ }
+ case "2-digit": {
+ string += digits(value, 2).slice(-2);
+ break;
+ }
+ default:
+ throw Error(
+ `FormatterError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "month": {
+ const value = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
+ switch (token.value) {
+ case "numeric": {
+ string += value;
+ break;
+ }
+ case "2-digit": {
+ string += digits(value, 2);
+ break;
+ }
+ default:
+ throw Error(
+ `FormatterError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "day": {
+ const value = utc ? date.getUTCDate() : date.getDate();
+ switch (token.value) {
+ case "numeric": {
+ string += value;
+ break;
+ }
+ case "2-digit": {
+ string += digits(value, 2);
+ break;
+ }
+ default:
+ throw Error(
+ `FormatterError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "hour": {
+ let value = utc ? date.getUTCHours() : date.getHours();
+ value -= hour12 && date.getHours() > 12 ? 12 : 0;
+ switch (token.value) {
+ case "numeric": {
+ string += value;
+ break;
+ }
+ case "2-digit": {
+ string += digits(value, 2);
+ break;
+ }
+ default:
+ throw Error(
+ `FormatterError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "minute": {
+ const value = utc ? date.getUTCMinutes() : date.getMinutes();
+ switch (token.value) {
+ case "numeric": {
+ string += value;
+ break;
+ }
+ case "2-digit": {
+ string += digits(value, 2);
+ break;
+ }
+ default:
+ throw Error(
+ `FormatterError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "second": {
+ const value = utc ? date.getUTCSeconds() : date.getSeconds();
+ switch (token.value) {
+ case "numeric": {
+ string += value;
+ break;
+ }
+ case "2-digit": {
+ string += digits(value, 2);
+ break;
+ }
+ default:
+ throw Error(
+ `FormatterError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "fractionalSecond": {
+ const value = utc
+ ? date.getUTCMilliseconds()
+ : date.getMilliseconds();
+ string += digits(value, Number(token.value));
+ break;
+ }
+ case "timeZoneName": {
+ // string += utc ? "Z" : token.value
+ // break
+ }
+ case "dayPeriod": {
+ string += hour12 ? (date.getHours() >= 12 ? "PM" : "AM") : "";
+ break;
+ }
+ case "literal": {
+ string += token.value;
+ break;
+ }
+
+ default:
+ throw Error(`FormatterError: { ${token.type} ${token.value} }`);
+ }
+ }
+
+ return string;
+ }
+
+ parseToParts(string: string): DateTimeFormatPart[] {
+ const parts: DateTimeFormatPart[] = [];
+
+ for (const token of this.#format) {
+ const type = token.type;
+
+ let value = "";
+ switch (token.type) {
+ case "year": {
+ switch (token.value) {
+ case "numeric": {
+ value = /^\d{1,4}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "2-digit": {
+ value = /^\d{1,2}/.exec(string)?.[0] as string;
+ break;
+ }
+ }
+ break;
+ }
+ case "month": {
+ switch (token.value) {
+ case "numeric": {
+ value = /^\d{1,2}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "2-digit": {
+ value = /^\d{2}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "narrow": {
+ value = /^[a-zA-Z]+/.exec(string)?.[0] as string;
+ break;
+ }
+ case "short": {
+ value = /^[a-zA-Z]+/.exec(string)?.[0] as string;
+ break;
+ }
+ case "long": {
+ value = /^[a-zA-Z]+/.exec(string)?.[0] as string;
+ break;
+ }
+ default:
+ throw Error(
+ `ParserError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "day": {
+ switch (token.value) {
+ case "numeric": {
+ value = /^\d{1,2}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "2-digit": {
+ value = /^\d{2}/.exec(string)?.[0] as string;
+ break;
+ }
+ default:
+ throw Error(
+ `ParserError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "hour": {
+ switch (token.value) {
+ case "numeric": {
+ value = /^\d{1,2}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "2-digit": {
+ value = /^\d{2}/.exec(string)?.[0] as string;
+ break;
+ }
+ default:
+ throw Error(
+ `ParserError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "minute": {
+ switch (token.value) {
+ case "numeric": {
+ value = /^\d{1,2}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "2-digit": {
+ value = /^\d{2}/.exec(string)?.[0] as string;
+ break;
+ }
+ default:
+ throw Error(
+ `ParserError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "second": {
+ switch (token.value) {
+ case "numeric": {
+ value = /^\d{1,2}/.exec(string)?.[0] as string;
+ break;
+ }
+ case "2-digit": {
+ value = /^\d{2}/.exec(string)?.[0] as string;
+ break;
+ }
+ default:
+ throw Error(
+ `ParserError: value "${token.value}" is not supported`,
+ );
+ }
+ break;
+ }
+ case "fractionalSecond": {
+ value = new RegExp(`^\\d{${token.value}}`).exec(
+ string,
+ )?.[0] as string;
+ break;
+ }
+ case "timeZoneName": {
+ value = token.value as string;
+ break;
+ }
+ case "dayPeriod": {
+ value = /^(A|P)M/.exec(string)?.[0] as string;
+ break;
+ }
+ case "literal": {
+ if (!string.startsWith(token.value as string)) {
+ throw Error(
+ `Literal "${token.value}" not found "${string.slice(0, 25)}"`,
+ );
+ }
+ value = token.value as string;
+ break;
+ }
+
+ default:
+ throw Error(`${token.type} ${token.value}`);
+ }
+
+ if (!value) {
+ throw Error(
+ `value not valid for token { ${type} ${value} } ${
+ string.slice(
+ 0,
+ 25,
+ )
+ }`,
+ );
+ }
+ parts.push({ type, value });
+ string = string.slice(value.length);
+ }
+
+ if (string.length) {
+ throw Error(
+ `datetime string was not fully parsed! ${string.slice(0, 25)}`,
+ );
+ }
+
+ return parts;
+ }
+
+ partsToDate(parts: DateTimeFormatPart[]): Date {
+ const date = new Date();
+ const utc = parts.find(
+ (part) => part.type === "timeZoneName" && part.value === "UTC",
+ );
+
+ utc ? date.setUTCHours(0, 0, 0, 0) : date.setHours(0, 0, 0, 0);
+ for (const part of parts) {
+ switch (part.type) {
+ case "year": {
+ const value = Number(part.value.padStart(4, "20"));
+ utc ? date.setUTCFullYear(value) : date.setFullYear(value);
+ break;
+ }
+ case "month": {
+ const value = Number(part.value) - 1;
+ utc ? date.setUTCMonth(value) : date.setMonth(value);
+ break;
+ }
+ case "day": {
+ const value = Number(part.value);
+ utc ? date.setUTCDate(value) : date.setDate(value);
+ break;
+ }
+ case "hour": {
+ let value = Number(part.value);
+ const dayPeriod = parts.find(
+ (part: DateTimeFormatPart) => part.type === "dayPeriod",
+ );
+ if (dayPeriod?.value === "PM") value += 12;
+ utc ? date.setUTCHours(value) : date.setHours(value);
+ break;
+ }
+ case "minute": {
+ const value = Number(part.value);
+ utc ? date.setUTCMinutes(value) : date.setMinutes(value);
+ break;
+ }
+ case "second": {
+ const value = Number(part.value);
+ utc ? date.setUTCSeconds(value) : date.setSeconds(value);
+ break;
+ }
+ case "fractionalSecond": {
+ const value = Number(part.value);
+ utc ? date.setUTCMilliseconds(value) : date.setMilliseconds(value);
+ break;
+ }
+ }
+ }
+ return date;
+ }
+
+ parse(string: string): Date {
+ const parts = this.parseToParts(string);
+ return this.partsToDate(parts);
+ }
+}