From b05054edd5622c8ce811057d4b7a877cec2c112f Mon Sep 17 00:00:00 2001 From: Tilman Roeder Date: Tue, 28 Dec 2021 15:40:37 +0000 Subject: [PATCH] Add support for date argument types --- mod.ts | 2 +- src/types.ts | 56 ++++++++++++++++++++++++++++++++ src/types_test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/mod.ts b/mod.ts index 3bd078b..32523e0 100644 --- a/mod.ts +++ b/mod.ts @@ -3,4 +3,4 @@ export type { ArgumentOptions, FlagOptions } from "./src/command.ts"; export { Command } from "./src/command.ts"; export { CommandGroup } from "./src/group.ts"; -export { boolean, choice, integer, number, string } from "./src/types.ts"; +export { boolean, choice, date, integer, number, string } from "./src/types.ts"; diff --git a/src/types.ts b/src/types.ts index ec45b47..a8e3e95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,19 @@ export interface ArgumentType { readonly typeName: string; } +const ISO_8601 = + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(([+-]\d\d:\d\d)|Z)?$/i; +const LOCAL_DATE_FORMATS = [ + /^(?\d{4})-(?\d\d?)-(?\d\d?)(\s+(?\d\d?):(?\d\d)(:(?\d\d))?)?$/i, + /^(?\d{4}).(?\d\d?).(?\d\d?)(\s+(?\d\d?):(?\d\d)(:(?\d\d))?)?$/i, + /^(?\d{4}) (?\d\d?) (?\d\d?)(\s+(?\d\d?):(?\d\d)(:(?\d\d))?)?$/i, + /^(?\d{4})\/(?\d\d?)\/(?\d\d?)(\s+(?\d\d?):(?\d\d)(:(?\d\d))?)?$/i, + /^(?\d\d?):(?\d\d)(:(?\d\d))?\s+(?\d{4})-(?\d\d?)-(?\d\d?)$/i, + /^(?\d\d?):(?\d\d)(:(?\d\d))?\s+(?\d{4}).(?\d\d?).(?\d\d?)$/i, + /^(?\d\d?):(?\d\d)(:(?\d\d))?\s+(?\d{4}) (?\d\d?) (?\d\d?)$/i, + /^(?\d\d?):(?\d\d)(:(?\d\d))?\s+(?\d{4})\/(?\d\d)\/(?\d\d)$/i, +]; + function escapeRawArgument(string: string) { return `'${string.replaceAll("'", "\\'")}'`; } @@ -88,6 +101,49 @@ export const boolean: ArgumentType = Object.freeze({ typeName: "BOOLEAN", }); +/** + * Returns the provided CLI argument as a `Date`. This supports local + * dates and times, ISO8601 strings, and the special constants `now` + * (current time) and `today` (start of current day in local time-zone). + */ +export const date: ArgumentType = Object.freeze({ + parse: (raw: string): Date => { + const trimmed = raw.trim().toLowerCase(); + if (trimmed.match(ISO_8601)) { + return new Date(trimmed); + } else if (trimmed === "now") { + return new Date(); + } else if (trimmed === "today") { + const date = new Date(); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; + } else { + for (const format of LOCAL_DATE_FORMATS) { + const match = trimmed.match(format); + if (match != null) { + const year = parseInt(match.groups!.year); + const month = parseInt(match.groups!.month) - 1; + if (month < 0 || 11 < month) continue; + const day = parseInt(match.groups!.day); + if (day < 1 || 31 < day) continue; + const hours = parseInt(match.groups!.hours ?? "0"); + if (hours < 0 || 23 < hours) continue; + const minutes = parseInt(match.groups!.minutes ?? "0"); + if (minutes < 0 || 59 < minutes) continue; + const seconds = parseInt(match.groups!.seconds ?? "0"); + if (seconds < 0 || 59 < seconds) continue; + return new Date(year, month, day, hours, minutes, seconds); + } + } + throw new Error(`${escapeRawArgument(raw)} is not a valid date`); + } + }, + typeName: "DATE", +}); + /** * Returns a value from a provided set of options, if the CLI argument * matches. Options are matched case-insensitively e.g. diff --git a/src/types_test.ts b/src/types_test.ts index dd5e239..3fff5e3 100644 --- a/src/types_test.ts +++ b/src/types_test.ts @@ -1,5 +1,6 @@ -import { boolean, choice, integer, number, string } from "./types.ts"; +import { boolean, choice, date, integer, number, string } from "./types.ts"; import { + assert, assertEquals, assertThrows, } from "https://deno.land/std@0.118.0/testing/asserts.ts"; @@ -114,6 +115,86 @@ Deno.test("boolean type", () => { for (const raw of bad) assertThrows(() => boolean.parse(raw)); }); +Deno.test("date type", () => { + const dateUTC = ( + year: number, + month: number, + day: number, + hours: number, + minutes: number, + seconds: number, + ): Date => { + const date = new Date(); + date.setUTCFullYear(year); + date.setUTCMonth(month - 1); + date.setUTCDate(day); + date.setUTCHours(hours); + date.setUTCMinutes(minutes); + date.setUTCSeconds(seconds); + date.setUTCMilliseconds(0); + return date; + }; + const dateLocal = ( + year: number, + month: number, + day: number, + hours: number, + minutes: number, + seconds: number, + ): Date => { + const date = new Date(); + date.setFullYear(year); + date.setMonth(month - 1); + date.setDate(day); + date.setHours(hours); + date.setMinutes(minutes); + date.setSeconds(seconds); + date.setMilliseconds(0); + return date; + }; + const good: [string, Date][] = [ + [ + "today", + new Date( + new Date().getFullYear(), + new Date().getMonth(), + new Date().getDate(), + ), + ], + ["1999-11-20T12:00:00Z", dateUTC(1999, 11, 20, 12, 0, 0)], + ["2021/12/24", dateLocal(2021, 12, 24, 0, 0, 0)], + ["2021.12.24", dateLocal(2021, 12, 24, 0, 0, 0)], + ["2021-12-24", dateLocal(2021, 12, 24, 0, 0, 0)], + ["2006-1-9", dateLocal(2006, 1, 9, 0, 0, 0)], + ["17:32:11 2006-1-9", dateLocal(2006, 1, 9, 17, 32, 11)], + ["2006/1/9 17:32", dateLocal(2006, 1, 9, 17, 32, 0)], + ["17:32:11 2006-1-9", dateLocal(2006, 1, 9, 17, 32, 11)], + ["2012-8-16 6:19", dateLocal(2012, 8, 16, 6, 19, 0)], + ]; + const bad = [ + "wfwe", + "", + " ", + "1992/13/09", + "18:46", + "2021/11/11 25:11:52", + "2021/11/11 6:60:52", + ]; + + // Don't directly compare, since the exact time can change + // slightly between the two creations. + const now = date.parse("now"); + assert( + Math.abs(now.valueOf() - Date.now()) < 0.01, + "now should return current time", + ); + + for (const [raw, value] of lowerAndUpperCase(addPadding(good))) { + assertEquals(date.parse(raw), value); + } + for (const raw of bad) assertThrows(() => date.parse(raw)); +}); + Deno.test("choice type", () => { const choices = ["a", "b", "c", "test", "Hello", "World"]; const alsoChoices: [string, string][] = [