From 684eddcc6bf0c1446c9aba0cdf001c661c19ab24 Mon Sep 17 00:00:00 2001 From: Tim Reichen Date: Sat, 15 Aug 2020 16:37:17 +0200 Subject: [PATCH] feat(std/datetime): generalise parser, add formatter (#6619) --- std/datetime/README.md | 61 ++++- std/datetime/formatter.ts | 534 ++++++++++++++++++++++++++++++++++++++ std/datetime/mod.ts | 90 +------ std/datetime/test.ts | 98 ++++--- std/datetime/tokenizer.ts | 69 +++++ 5 files changed, 728 insertions(+), 124 deletions(-) create mode 100644 std/datetime/formatter.ts create mode 100644 std/datetime/tokenizer.ts diff --git a/std/datetime/README.md b/std/datetime/README.md index e9142f48ad..ac505ad160 100644 --- a/std/datetime/README.md +++ b/std/datetime/README.md @@ -4,22 +4,63 @@ Simple helper to help parse date strings into `Date`, with additional functions. ## Usage -### parseDate / parseDateTime +The following symbols are supported: -- `parseDate()` - Take an input string and a format to parse the date. Supported - formats are exported in `DateFormat`. -- `parseDateTime()` - Take an input string and a format to parse the dateTime. - Supported formats are exported in `DateTimeFormat`. +- `yyyy` - numeric year +- `yy` - 2-digit year +- `M` - numeric month +- `MM` - 2-digit month +- `d` - numeric day +- `dd` - 2-digit day + +- `h` - numeric hour +- `hh` - 2-digit hour +- `m` - numeric minute +- `mm` - 2-digit minute +- `s` - numeric second +- `ss` - 2-digit second +- `S` - 1-digit fractionalSecond +- `SS` - 2-digit fractionalSecond +- `SSS` - 3-digit fractionalSecond + +- `a` - dayPeriod, either `AM` or `PM` + +- `'foo'` - quoted literal +- `./-` - unquoted literal + +### parse + +Takes an input `string` and a `formatString` to parse to a `date`. ```ts -import { parseDate, parseDateTime } from 'https://deno.land/std/datetime/mod.ts' +import { parse } from 'https://deno.land/std/datetime/mod.ts' -parseDate("20-01-2019", "dd-mm-yyyy") // output : new Date(2019, 0, 20) -parseDate("2019-01-20", "yyyy-mm-dd") // output : new Date(2019, 0, 20) +parse("20-01-2019", "dd-MM-yyyy") // output : new Date(2019, 0, 20) +parse("2019-01-20", "yyyy-MM-dd") // output : new Date(2019, 0, 20) +parse("2019-01-20", "dd.MM.yyyy") // output : new Date(2019, 0, 20) +parse("01-20-2019 16:34", "MM-dd-yyyy hh:mm") // output : new Date(2019, 0, 20, 16, 34) +parse("01-20-2019 04:34 PM", "MM-dd-yyyy hh:mm a") // output : new Date(2019, 0, 20, 16, 34) +parse("16:34 01-20-2019", "hh:mm MM-dd-yyyy") // output : new Date(2019, 0, 20, 16, 34) +parse("01-20-2019 16:34:23.123", "MM-dd-yyyy hh:mm:ss.SSS") // output : new Date(2019, 0, 20, 16, 34, 23, 123) ... +``` + +### format + +Takes an input `date` and a `formatString` to format to a `string`. + +```ts +import { format } from 'https://deno.land/std/datetime/mod.ts' + +format(new Date(2019, 0, 20), "dd-MM-yyyy") // output : "20-01-2019" +format(new Date(2019, 0, 20), "yyyy-MM-dd") // output : "2019-01-20" +format(new Date(2019, 0, 20), "dd.MM.yyyy") // output : "2019-01-20" +format(new Date(2019, 0, 20, 16, 34), "MM-dd-yyyy hh:mm") // output : "01-20-2019 16:34" +format(new Date(2019, 0, 20, 16, 34), "MM-dd-yyyy hh:mm a") // output : "01-20-2019 04:34 PM" +format(new Date(2019, 0, 20, 16, 34), "hh:mm MM-dd-yyyy") // output : "16:34 01-20-2019" +format(new Date(2019, 0, 20, 16, 34, 23, 123), "MM-dd-yyyy hh:mm:ss.SSS") // output : "01-20-2019 16:34:23.123" +format(new Date(2019, 0, 20), "'today:' yyyy-MM-dd") // output : "today: 2019-01-20" -parseDateTime("01-20-2019 16:34", "mm-dd-yyyy hh:mm") // output : new Date(2019, 0, 20, 16, 34) -parseDateTime("16:34 01-20-2019", "hh:mm mm-dd-yyyy") // output : new Date(2019, 0, 20, 16, 34) ... ``` diff --git a/std/datetime/formatter.ts b/std/datetime/formatter.ts new file mode 100644 index 0000000000..0e872cb401 --- /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(/^(')(?\\.|[^\']*)\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); + } +} diff --git a/std/datetime/mod.ts b/std/datetime/mod.ts index fe6fe6b3c2..e47fe9e01a 100644 --- a/std/datetime/mod.ts +++ b/std/datetime/mod.ts @@ -1,7 +1,6 @@ // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. -import { assert } from "../_util/assert.ts"; -export type DateFormat = "mm-dd-yyyy" | "dd-mm-yyyy" | "yyyy-mm-dd"; +import { DateTimeFormatter } from "./formatter.ts"; export const SECOND = 1e3; export const MINUTE = SECOND * 60; @@ -20,92 +19,27 @@ enum Day { Sat, } -function execForce(reg: RegExp, pat: string): RegExpExecArray { - const v = reg.exec(pat); - assert(v != null); - return v; -} /** * Parse date from string using format string - * @param dateStr Date string + * @param dateString Date string * @param format Format string * @return Parsed date */ -export function parseDate(dateStr: string, format: DateFormat): Date { - let m, d, y: string; - let datePattern: RegExp; - - switch (format) { - case "mm-dd-yyyy": - datePattern = /^(\d{2})-(\d{2})-(\d{4})$/; - [, m, d, y] = execForce(datePattern, dateStr); - break; - case "dd-mm-yyyy": - datePattern = /^(\d{2})-(\d{2})-(\d{4})$/; - [, d, m, y] = execForce(datePattern, dateStr); - break; - case "yyyy-mm-dd": - datePattern = /^(\d{4})-(\d{2})-(\d{2})$/; - [, y, m, d] = execForce(datePattern, dateStr); - break; - default: - throw new Error("Invalid date format!"); - } - - return new Date(Number(y), Number(m) - 1, Number(d)); +export function parse(dateString: string, formatString: string): Date { + const formatter = new DateTimeFormatter(formatString); + const parts = formatter.parseToParts(dateString); + return formatter.partsToDate(parts); } -export type DateTimeFormat = - | "mm-dd-yyyy hh:mm" - | "dd-mm-yyyy hh:mm" - | "yyyy-mm-dd hh:mm" - | "hh:mm mm-dd-yyyy" - | "hh:mm dd-mm-yyyy" - | "hh:mm yyyy-mm-dd"; - /** - * Parse date & time from string using format string - * @param dateStr Date & time string + * Format date using format string + * @param date Date * @param format Format string - * @return Parsed date + * @return formatted date string */ -export function parseDateTime( - datetimeStr: string, - format: DateTimeFormat, -): Date { - let m, d, y, ho, mi: string; - let datePattern: RegExp; - - switch (format) { - case "mm-dd-yyyy hh:mm": - datePattern = /^(\d{2})-(\d{2})-(\d{4}) (\d{2}):(\d{2})$/; - [, m, d, y, ho, mi] = execForce(datePattern, datetimeStr); - break; - case "dd-mm-yyyy hh:mm": - datePattern = /^(\d{2})-(\d{2})-(\d{4}) (\d{2}):(\d{2})$/; - [, d, m, y, ho, mi] = execForce(datePattern, datetimeStr); - break; - case "yyyy-mm-dd hh:mm": - datePattern = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/; - [, y, m, d, ho, mi] = execForce(datePattern, datetimeStr); - break; - case "hh:mm mm-dd-yyyy": - datePattern = /^(\d{2}):(\d{2}) (\d{2})-(\d{2})-(\d{4})$/; - [, ho, mi, m, d, y] = execForce(datePattern, datetimeStr); - break; - case "hh:mm dd-mm-yyyy": - datePattern = /^(\d{2}):(\d{2}) (\d{2})-(\d{2})-(\d{4})$/; - [, ho, mi, d, m, y] = execForce(datePattern, datetimeStr); - break; - case "hh:mm yyyy-mm-dd": - datePattern = /^(\d{2}):(\d{2}) (\d{4})-(\d{2})-(\d{2})$/; - [, ho, mi, y, m, d] = execForce(datePattern, datetimeStr); - break; - default: - throw new Error("Invalid datetime format!"); - } - - return new Date(Number(y), Number(m) - 1, Number(d), Number(ho), Number(mi)); +export function format(date: Date, formatString: string): string { + const formatter = new DateTimeFormatter(formatString); + return formatter.format(date); } /** diff --git a/std/datetime/test.ts b/std/datetime/test.ts index d5dccee73c..7e81d30362 100644 --- a/std/datetime/test.ts +++ b/std/datetime/test.ts @@ -3,77 +3,103 @@ import { assert, assertEquals, assertThrows } from "../testing/asserts.ts"; import * as datetime from "./mod.ts"; Deno.test({ - name: "[std/datetime] parseDate", + name: "[std/datetime] parse", fn: () => { assertEquals( - datetime.parseDateTime("01-03-2019 16:30", "mm-dd-yyyy hh:mm"), + datetime.parse("01-03-2019 16:30", "MM-dd-yyyy hh:mm"), new Date(2019, 0, 3, 16, 30), ); assertEquals( - datetime.parseDateTime("03-01-2019 16:31", "dd-mm-yyyy hh:mm"), + datetime.parse("01.03.2019 16:30", "MM.dd.yyyy hh:mm"), + new Date(2019, 0, 3, 16, 30), + ); + assertEquals( + datetime.parse("03-01-2019 16:31", "dd-MM-yyyy hh:mm"), new Date(2019, 0, 3, 16, 31), ); assertEquals( - datetime.parseDateTime("2019-01-03 16:32", "yyyy-mm-dd hh:mm"), + datetime.parse("2019-01-03 16:32", "yyyy-MM-dd hh:mm"), new Date(2019, 0, 3, 16, 32), ); assertEquals( - datetime.parseDateTime("16:33 01-03-2019", "hh:mm mm-dd-yyyy"), + datetime.parse("16:33 01-03-2019", "hh:mm MM-dd-yyyy"), new Date(2019, 0, 3, 16, 33), ); assertEquals( - datetime.parseDateTime("16:34 03-01-2019", "hh:mm dd-mm-yyyy"), + datetime.parse("01-03-2019 16:33:23.123", "MM-dd-yyyy hh:mm:ss.SSS"), + new Date(2019, 0, 3, 16, 33, 23, 123), + ); + assertEquals( + datetime.parse("01-03-2019 09:33 PM", "MM-dd-yyyy hh:mm a"), + new Date(2019, 0, 3, 21, 33), + ); + assertEquals( + datetime.parse("16:34 03-01-2019", "hh:mm dd-MM-yyyy"), new Date(2019, 0, 3, 16, 34), ); assertEquals( - datetime.parseDateTime("16:35 2019-01-03", "hh:mm yyyy-mm-dd"), + datetime.parse("16:35 2019-01-03", "hh:mm yyyy-MM-dd"), new Date(2019, 0, 3, 16, 35), ); + assertEquals( + datetime.parse("01-03-2019", "MM-dd-yyyy"), + new Date(2019, 0, 3), + ); + assertEquals( + datetime.parse("03-01-2019", "dd-MM-yyyy"), + new Date(2019, 0, 3), + ); + assertEquals( + datetime.parse("2019-01-03", "yyyy-MM-dd"), + new Date(2019, 0, 3), + ); }, }); Deno.test({ name: "[std/datetime] invalidParseDateTimeFormatThrows", fn: () => { - assertThrows( - (): void => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (datetime as any).parseDateTime("2019-01-01 00:00", "x-y-z"); - }, - Error, - "Invalid datetime format!", - ); + assertThrows((): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (datetime as any).parse("2019-01-01 00:00", "x-y-z"); + }, Error); + assertThrows((): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (datetime as any).parse("2019-01-01", "x-y-z"); + }, Error); }, }); Deno.test({ - name: "[std/datetime] parseDate", + name: "[std/datetime] format", fn: () => { assertEquals( - datetime.parseDate("01-03-2019", "mm-dd-yyyy"), - new Date(2019, 0, 3), + "2019-01-01", + datetime.format(new Date("2019-01-01T03:24:00"), "yyyy-MM-dd"), ); assertEquals( - datetime.parseDate("03-01-2019", "dd-mm-yyyy"), - new Date(2019, 0, 3), + "01.01.2019", + datetime.format(new Date("2019-01-01T03:24:00"), "dd.MM.yyyy"), ); assertEquals( - datetime.parseDate("2019-01-03", "yyyy-mm-dd"), - new Date(2019, 0, 3), + "03:24:00", + datetime.format(new Date("2019-01-01T03:24:00"), "hh:mm:ss"), ); - }, -}); - -Deno.test({ - name: "[std/datetime] invalidParseDateFormatThrows", - fn: () => { - assertThrows( - (): void => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (datetime as any).parseDate("2019-01-01", "x-y-z"); - }, - Error, - "Invalid date format!", + assertEquals( + "03:24:00.532", + datetime.format(new Date("2019-01-01T03:24:00.532"), "hh:mm:ss.SSS"), + ); + assertEquals( + "03:24:00 AM", + datetime.format(new Date("2019-01-01T03:24:00"), "hh:mm:ss a"), + ); + assertEquals( + "09:24:00 PM", + datetime.format(new Date("2019-01-01T21:24:00"), "hh:mm:ss a"), + ); + assertEquals( + datetime.format(new Date(2019, 0, 20), "'today:' yyyy-MM-dd"), + "today: 2019-01-20", ); }, }); @@ -186,7 +212,7 @@ Deno.test({ }); Deno.test({ - name: "[std/datetime] Difference", + name: "[std/datetime] difference", fn(): void { const denoInit = new Date("2018/5/14"); const denoRelaseV1 = new Date("2020/5/13"); diff --git a/std/datetime/tokenizer.ts b/std/datetime/tokenizer.ts new file mode 100644 index 0000000000..05314b7706 --- /dev/null +++ b/std/datetime/tokenizer.ts @@ -0,0 +1,69 @@ +export type Token = { + type: string; + value: string | number; + index: number; +}; + +interface ReceiverResult { + [name: string]: string | number; +} +export type CallbackResult = { type: string; value: string | number }; +type CallbackFunction = (value: unknown) => CallbackResult; + +export type TestResult = { value: unknown; length: number } | undefined; +export type TestFunction = ( + string: string, +) => TestResult | undefined; + +export interface Rule { + test: TestFunction; + fn: CallbackFunction; +} + +export class Tokenizer { + rules: Rule[]; + + constructor(rules: Rule[] = []) { + this.rules = rules; + } + + addRule(test: TestFunction, fn: CallbackFunction): Tokenizer { + this.rules.push({ test, fn }); + return this; + } + + tokenize( + string: string, + receiver = (token: Token): ReceiverResult => token, + ): ReceiverResult[] { + function* generator(rules: Rule[]): IterableIterator { + let index = 0; + for (const rule of rules) { + const result = rule.test(string); + if (result) { + const { value, length } = result; + index += length; + string = string.slice(length); + const token = { ...rule.fn(value), index }; + yield receiver(token); + yield* generator(rules); + } + } + } + const tokenGenerator = generator(this.rules); + + const tokens: ReceiverResult[] = []; + + for (const token of tokenGenerator) { + tokens.push(token); + } + + if (string.length) { + throw new Error( + `parser error: string not fully parsed! ${string.slice(0, 25)}`, + ); + } + + return tokens; + } +}