From 8180d443b9938f135dd280bcd5e1727766cd7468 Mon Sep 17 00:00:00 2001 From: Matt Haggard Date: Wed, 8 May 2019 05:48:04 -0600 Subject: [PATCH] Allow for locale-based parsing/formatting of dates (#11170) * Allow for locale-based parsing/formatting of dates * Updates based on review feedback of PR 11170 DateTimeLocale arrays are now indexed by Month and WeekDay enums. More sane date used for testing. Documentation newline. Case change of DefaultLocale (and make it public) * Add changelog entry for DateTimeLocale addition to times module * Use pattern symbols for DateTimeLocale attribute names --- changelog.md | 2 + lib/pure/times.nim | 148 ++++++++++++++++------------------------ tests/stdlib/ttimes.nim | 27 ++++++++ 3 files changed, 86 insertions(+), 91 deletions(-) diff --git a/changelog.md b/changelog.md index adc35a0796..91b8f5bbdd 100644 --- a/changelog.md +++ b/changelog.md @@ -193,6 +193,8 @@ proc enumToString*(enums: openArray[enum]): string = - The switch ``-d:useWinAnsi`` is not supported anymore. +- In `times` module, procs `format` and `parse` accept a new optional `DateTimeLocale` argument for formatting/parsing dates in other languages. + ### Language additions diff --git a/lib/pure/times.nim b/lib/pure/times.nim index dc79877fdf..9312b7ba8b 100644 --- a/lib/pure/times.nim +++ b/lib/pure/times.nim @@ -282,6 +282,12 @@ type dFri = "Friday" dSat = "Saturday" dSun = "Sunday" + + DateTimeLocale* = object + MMM*: array[mJan..mDec, string] + MMMM*: array[mJan..mDec, string] + ddd*: array[dMon..dSun, string] + dddd*: array[dMon..dSun, string] MonthdayRange* = range[1..31] HourRange* = range[0..23] @@ -421,6 +427,13 @@ const unitWeights: array[FixedTimeUnit, int64] = [ 7 * secondsInDay * 1e9.int64, ] +const DefaultLocale* = DateTimeLocale( + MMM: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + MMMM: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + ddd: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + dddd: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], +) + proc convert*[T: SomeInteger](unitFrom, unitTo: FixedTimeUnit, quantity: T): T {.inline.} = ## Convert a quantity of some duration unit to another duration unit. @@ -1894,7 +1907,7 @@ proc initTimeFormat*(format: string): TimeFormat = of tkPattern: result.patterns.add(stringToPattern(token).byte) -proc formatPattern(dt: DateTime, pattern: FormatPattern, result: var string) = +proc formatPattern(dt: DateTime, pattern: FormatPattern, result: var string, loc: DateTimeLocale) = template yearOfEra(dt: DateTime): int = if dt.year <= 0: abs(dt.year) + 1 else: dt.year @@ -1904,9 +1917,9 @@ proc formatPattern(dt: DateTime, pattern: FormatPattern, result: var string) = of dd: result.add dt.monthday.intToStr(2) of ddd: - result.add ($dt.weekday)[0..2] + result.add loc.ddd[dt.weekday] of dddd: - result.add $dt.weekday + result.add loc.dddd[dt.weekday] of h: result.add( if dt.hour == 0: "12" @@ -1932,9 +1945,9 @@ proc formatPattern(dt: DateTime, pattern: FormatPattern, result: var string) = of MM: result.add ord(dt.month).intToStr(2) of MMM: - result.add ($dt.month)[0..2] + result.add loc.MMM[dt.month] of MMMM: - result.add $dt.month + result.add loc.MMMM[dt.month] of s: result.add $dt.second of ss: @@ -2003,7 +2016,7 @@ proc formatPattern(dt: DateTime, pattern: FormatPattern, result: var string) = of Lit: assert false # Can't happen proc parsePattern(input: string, pattern: FormatPattern, i: var int, - parsed: var ParsedTime): bool = + parsed: var ParsedTime, loc: DateTimeLocale): bool = template takeInt(allowedWidth: Slice[int], allowSign = false): int = var sv: int var pd = parseInt(input, sv, i, allowedWidth.b, allowSign) @@ -2027,27 +2040,19 @@ proc parsePattern(input: string, pattern: FormatPattern, i: var int, parsed.monthday = some(monthday) result = monthday in MonthdayRange of ddd: - result = input.substr(i, i+2).toLowerAscii() in [ - "sun", "mon", "tue", "wed", "thu", "fri", "sat"] - if result: - i.inc 3 + result = false + for v in loc.ddd: + if input.substr(i, i+v.len-1).cmpIgnoreCase(v) == 0: + result = true + i.inc v.len + break of dddd: - if input.substr(i, i+5).cmpIgnoreCase("sunday") == 0: - i.inc 6 - elif input.substr(i, i+5).cmpIgnoreCase("monday") == 0: - i.inc 6 - elif input.substr(i, i+6).cmpIgnoreCase("tuesday") == 0: - i.inc 7 - elif input.substr(i, i+8).cmpIgnoreCase("wednesday") == 0: - i.inc 9 - elif input.substr(i, i+7).cmpIgnoreCase("thursday") == 0: - i.inc 8 - elif input.substr(i, i+5).cmpIgnoreCase("friday") == 0: - i.inc 6 - elif input.substr(i, i+7).cmpIgnoreCase("saturday") == 0: - i.inc 8 - else: - result = false + result = false + for v in loc.dddd: + if input.substr(i, i+v.len-1).cmpIgnoreCase(v) == 0: + result = true + i.inc v.len + break of h, H: parsed.hour = takeInt(1..2) result = parsed.hour in HourRange @@ -2069,62 +2074,21 @@ proc parsePattern(input: string, pattern: FormatPattern, i: var int, result = month in 1..12 parsed.month = some(month) of MMM: - case input.substr(i, i+2).toLowerAscii() - of "jan": parsed.month = some(1) - of "feb": parsed.month = some(2) - of "mar": parsed.month = some(3) - of "apr": parsed.month = some(4) - of "may": parsed.month = some(5) - of "jun": parsed.month = some(6) - of "jul": parsed.month = some(7) - of "aug": parsed.month = some(8) - of "sep": parsed.month = some(9) - of "oct": parsed.month = some(10) - of "nov": parsed.month = some(11) - of "dec": parsed.month = some(12) - else: - result = false - if result: - i.inc 3 + result = false + for n,v in loc.MMM: + if input.substr(i, i+v.len-1).cmpIgnoreCase(v) == 0: + result = true + i.inc v.len + parsed.month = some(n.int) + break of MMMM: - if input.substr(i, i+6).cmpIgnoreCase("january") == 0: - parsed.month = some(1) - i.inc 7 - elif input.substr(i, i+7).cmpIgnoreCase("february") == 0: - parsed.month = some(2) - i.inc 8 - elif input.substr(i, i+4).cmpIgnoreCase("march") == 0: - parsed.month = some(3) - i.inc 5 - elif input.substr(i, i+4).cmpIgnoreCase("april") == 0: - parsed.month = some(4) - i.inc 5 - elif input.substr(i, i+2).cmpIgnoreCase("may") == 0: - parsed.month = some(5) - i.inc 3 - elif input.substr(i, i+3).cmpIgnoreCase("june") == 0: - parsed.month = some(6) - i.inc 4 - elif input.substr(i, i+3).cmpIgnoreCase("july") == 0: - parsed.month = some(7) - i.inc 4 - elif input.substr(i, i+5).cmpIgnoreCase("august") == 0: - parsed.month = some(8) - i.inc 6 - elif input.substr(i, i+8).cmpIgnoreCase("september") == 0: - parsed.month = some(9) - i.inc 9 - elif input.substr(i, i+6).cmpIgnoreCase("october") == 0: - parsed.month = some(10) - i.inc 7 - elif input.substr(i, i+7).cmpIgnoreCase("november") == 0: - parsed.month = some(11) - i.inc 8 - elif input.substr(i, i+7).cmpIgnoreCase("december") == 0: - parsed.month = some(12) - i.inc 8 - else: - result = false + result = false + for n,v in loc.MMMM: + if input.substr(i, i+v.len-1).cmpIgnoreCase(v) == 0: + result = true + i.inc v.len + parsed.month = some(n.int) + break of s: parsed.second = takeInt(1..2) of ss: @@ -2294,7 +2258,7 @@ proc toDateTime(p: ParsedTime, zone: Timezone, f: TimeFormat, result.utcOffset = p.utcOffset.get() result = result.toTime.inZone(zone) -proc format*(dt: DateTime, f: TimeFormat): string {.raises: [].} = +proc format*(dt: DateTime, f: TimeFormat, loc: DateTimeLocale = DefaultLocale): string {.raises: [].} = ## Format ``dt`` using the format specified by ``f``. runnableExamples: let f = initTimeFormat("yyyy-MM-dd") @@ -2311,10 +2275,10 @@ proc format*(dt: DateTime, f: TimeFormat): string {.raises: [].} = result.add f.patterns[idx].char idx.inc else: - formatPattern(dt, f.patterns[idx].FormatPattern, result = result) + formatPattern(dt, f.patterns[idx].FormatPattern, result = result, loc = loc) idx.inc -proc format*(dt: DateTime, f: string): string +proc format*(dt: DateTime, f: string, loc: DateTimeLocale = DefaultLocale): string {.raises: [TimeFormatParseError].} = ## Shorthand for constructing a ``TimeFormat`` and using it to format ``dt``. ## @@ -2324,7 +2288,7 @@ proc format*(dt: DateTime, f: string): string let dt = initDateTime(01, mJan, 2000, 00, 00, 00, utc()) doAssert "2000-01-01" == format(dt, "yyyy-MM-dd") let dtFormat = initTimeFormat(f) - result = dt.format(dtFormat) + result = dt.format(dtFormat, loc) proc format*(dt: DateTime, f: static[string]): string {.raises: [].} = ## Overload that validates ``format`` at compile time. @@ -2358,12 +2322,14 @@ template formatValue*(result: var string; value: Time, specifier: string) = ## adapter for strformat. Not intended to be called directly. result.add format(value, specifier) -proc parse*(input: string, f: TimeFormat, zone: Timezone = local()): DateTime +proc parse*(input: string, f: TimeFormat, zone: Timezone = local(), loc: DateTimeLocale = DefaultLocale): DateTime {.raises: [TimeParseError, Defect].} = ## Parses ``input`` as a ``DateTime`` using the format specified by ``f``. ## If no UTC offset was parsed, then ``input`` is assumed to be specified in ## the ``zone`` timezone. If a UTC offset was parsed, the result will be ## converted to the ``zone`` timezone. + ## + ## Month and day names from the passed in ``loc`` are used. runnableExamples: let f = initTimeFormat("yyyy-MM-dd") let dt = initDateTime(01, mJan, 2000, 00, 00, 00, utc()) @@ -2385,7 +2351,7 @@ proc parse*(input: string, f: TimeFormat, zone: Timezone = local()): DateTime inpIdx.inc patIdx.inc else: - if not parsePattern(input, pattern, inpIdx, parsed): + if not parsePattern(input, pattern, inpIdx, parsed, loc): raiseParseException(f, input, "Failed on pattern '" & $pattern & "'") patIdx.inc @@ -2399,7 +2365,7 @@ proc parse*(input: string, f: TimeFormat, zone: Timezone = local()): DateTime result = toDateTime(parsed, zone, f, input) -proc parse*(input, f: string, tz: Timezone = local()): DateTime +proc parse*(input, f: string, tz: Timezone = local(), loc: DateTimeLocale = DefaultLocale): DateTime {.raises: [TimeParseError, TimeFormatParseError, Defect].} = ## Shorthand for constructing a ``TimeFormat`` and using it to parse ## ``input`` as a ``DateTime``. @@ -2410,13 +2376,13 @@ proc parse*(input, f: string, tz: Timezone = local()): DateTime let dt = initDateTime(01, mJan, 2000, 00, 00, 00, utc()) doAssert dt == parse("2000-01-01", "yyyy-MM-dd", utc()) let dtFormat = initTimeFormat(f) - result = input.parse(dtFormat, tz) + result = input.parse(dtFormat, tz, loc = loc) -proc parse*(input: string, f: static[string], zone: Timezone = local()): +proc parse*(input: string, f: static[string], zone: Timezone = local(), loc: DateTimeLocale = DefaultLocale): DateTime {.raises: [TimeParseError, Defect].} = ## Overload that validates ``f`` at compile time. const f2 = initTimeFormat(f) - result = input.parse(f2, zone) + result = input.parse(f2, zone, loc = loc) proc parseTime*(input, f: string, zone: Timezone): Time {.raises: [TimeParseError, TimeFormatParseError, Defect].} = diff --git a/tests/stdlib/ttimes.nim b/tests/stdlib/ttimes.nim index b29f5090b4..ef6712171a 100644 --- a/tests/stdlib/ttimes.nim +++ b/tests/stdlib/ttimes.nim @@ -452,6 +452,23 @@ suite "ttimes": doAssert dt.format("zz") == tz[2] doAssert dt.format("zzz") == tz[3] + test "format locale": + let loc = DateTimeLocale( + MMM: ["Fir","Sec","Thi","Fou","Fif","Six","Sev","Eig","Nin","Ten","Ele","Twe"], + MMMM: ["Firsty", "Secondy", "Thirdy", "Fourthy", "Fifthy", "Sixthy", "Seventhy", "Eighthy", "Ninthy", "Tenthy", "Eleventhy", "Twelfthy"], + ddd: ["Red", "Ora.", "Yel.", "Gre.", "Blu.", "Vio.", "Whi."], + dddd: ["Red", "Orange", "Yellow", "Green", "Blue", "Violet", "White"], + ) + var dt = initDateTime(5, mJan, 2010, 17, 01, 02, utc()) + check dt.format("d", loc) == "5" + check dt.format("dd", loc) == "05" + check dt.format("ddd", loc) == "Ora." + check dt.format("dddd", loc) == "Orange" + check dt.format("M", loc) == "1" + check dt.format("MM", loc) == "01" + check dt.format("MMM", loc) == "Fir" + check dt.format("MMMM", loc) == "Firsty" + test "parse": check $parse("20180101", "yyyyMMdd", utc()) == "2018-01-01T00:00:00Z" parseTestExcp("+120180101", "yyyyMMdd") @@ -473,6 +490,16 @@ suite "ttimes": parseTestExcp("2000 A", "yyyy g") + test "parse locale": + let loc = DateTimeLocale( + MMM: ["Fir","Sec","Thi","Fou","Fif","Six","Sev","Eig","Nin","Ten","Ele","Twe"], + MMMM: ["Firsty", "Secondy", "Thirdy", "Fourthy", "Fifthy", "Sixthy", "Seventhy", "Eighthy", "Ninthy", "Tenthy", "Eleventhy", "Twelfthy"], + ddd: ["Red", "Ora.", "Yel.", "Gre.", "Blu.", "Vio.", "Whi."], + dddd: ["Red", "Orange", "Yellow", "Green", "Blue", "Violet", "White"], + ) + check $parse("02 Fir 2019", "dd MMM yyyy", utc(), loc) == "2019-01-02T00:00:00Z" + check $parse("Fourthy 6, 2017", "MMMM d, yyyy", utc(), loc) == "2017-04-06T00:00:00Z" + test "countLeapYears": # 1920, 2004 and 2020 are leap years, and should be counted starting at the following year check countLeapYears(1920) + 1 == countLeapYears(1921)