1 /**
2   * datefmt provides parsing and formatting for std.datetime objects.
3   *
4   * The format is taken from strftime:
5   *    %a     The abbreviated name of the day of the week.
6   *    %A     The full name of the day of the week.
7   *    %b     The abbreviated month name.
8   *    %B     The full month name.
9   *    %C     The century number (year/100) as a 2-digit integer.
10   *    %d     The day of the month as a decimal number (range 01 to 31).
11   *    %e     Like %d, the day of the month as a decimal number, but space padded.
12   *    %f     Fractional seconds. Will parse any precision and emit six decimal places.
13   *    %F     Equivalent to %Y-%m-%d (the ISO 8601 date format).
14   *    %g     Milliseconds of the second.
15   *    %G     Nanoseconds of the second.
16   *    %h     The hour as a decimal number using a 12-hour clock (range 01 to 12).
17   *    %H     The hour as a decimal number using a 24-hour clock (range 00 to 23).
18   *    %I     The hour as a decimal number using a 12-hour clock (range 00 to 23).
19   *    %j     The day of the year as a decimal number (range 001 to 366).
20   *    %k     The hour (24-hour clock) as a decimal number (range 0 to 23), space padded.
21   *    %l     The hour (12-hour clock) as a decimal number (range 1 to 12), space padded.
22   *    %m     The month as a decimal number (range 01 to 12).
23   *    %M     The minute as a decimal number (range 00 to 59).
24   *    %p     "AM" / "PM" (midnight is AM; noon is PM).
25   *    %P     "am" / "pm" (midnight is AM; noon is PM).
26   *    %r     Equivalent to "%I:%M:%S %p".
27   *    %R     Equivalent to "%H:%M".
28   *    %s     The number of seconds since the Epoch, 1970-01-01 00:00:00 +0000 (UTC).
29   *    %S     The second as a decimal number (range 00 to 60).
30   *    %T     Equivalent to "%H:%M:%S".
31   *    %u     The day of the week as a decimal, range 1 to 7, Monday being 1 (formatting only).
32   *    %V     The ISO 8601 week number (formatting only).
33   *    %w     The day of the week as a decimal, range 0 to 6, Sunday being 0 (formatting only).
34   *    %y     The year as a decimal number without a century (range 00 to 99).
35   *    %Y     The year as a decimal number including the century, minimum 4 digits.
36   *    %z     The +hhmm or -hhmm numeric timezone (that is, the hour and minute offset from UTC).
37   *    %Z     The timezone name or abbreviation. Formatting only.
38   *    %+     The numeric offset, or 'Z' for UTC. This is common with ISO8601 timestamps.
39   *    %%     A literal '%' character.
40   *
41   * Timezone support is awkward. When time formats contain a GMT offset, that is honored. Otherwise,
42   * datefmt recognizes a subset of the timezone names defined in RFC1123.
43   */
44 module datefmt;
45 
46 @safe:
47 
48 import core.time;
49 import std.array;
50 import std.conv;
51 import std.datetime;
52 import std..string;
53 import std.utf : codeLength;
54 alias to = std.conv.to;
55 
56 
57 /**
58  * A Format is the platonic ideal of a specific format string.
59  *
60  * For datetime formats such as RFC1123 or ISO8601, there are many potential formats.
61  * Like '2017-01-01' is a valid ISO8601 date. So is '2017-01-01T15:31:00'.
62  * A Format object can describe all allomorphs of a format.
63  */
64 struct Format
65 {
66     /// The canonical format, to be used when formatting a datetime.
67     string primaryFormat;
68     /// Other formats that count as part of this Format, used for parsing.
69     string[] formatOptions;
70 }
71 
72 
73 /**
74  * Format the given datetime with the given Format.
75  */
76 string format(SysTime dt, const Format fmt)
77 {
78     return format(dt, fmt.primaryFormat);
79 }
80 
81 /**
82  * Format the given datetime with the given format string.
83  */
84 string format(SysTime dt, string formatString)
85 {
86     Appender!string ap;
87     bool inPercent;
88     foreach (i, c; formatString)
89     {
90         if (inPercent)
91         {
92             inPercent = false;
93             interpretIntoString(ap, dt, c);
94         }
95         else if (c == '%')
96         {
97             inPercent = true;
98         }
99         else
100         {
101             ap ~= c;
102         }
103     }
104     return ap.data;
105 }
106 
107 
108 /**
109  * Parse the given datetime string with the given format string.
110  *
111  * This tries rather hard to produce a reasonable result. If the format string doesn't describe an
112  * unambiguous point time, the result will be a date that satisfies the inputs and should generally
113  * be the earliest such date. However, that is not guaranteed.
114  *
115  * For instance:
116  * ---
117  * SysTime time = parse("%d", "21");
118  * writeln(time);  // 0000-01-21T00:00:00.000000Z
119  * ---
120  */
121 SysTime parse(
122         string data,
123         const Format fmt,
124         immutable(TimeZone) defaultTimeZone = null,
125         bool allowTrailingData = false)
126 {
127     SysTime st;
128     foreach (f; fmt.formatOptions)
129     {
130         if (tryParse(data, cast(string)f, st, defaultTimeZone))
131         {
132             return st;
133         }
134     }
135     return parse(data, fmt.primaryFormat, defaultTimeZone, allowTrailingData);
136 }
137 
138 
139 /**
140  * Parse the given datetime string with the given format string.
141  *
142  * This tries rather hard to produce a reasonable result. If the format string doesn't describe an
143  * unambiguous point time, the result will be a date that satisfies the inputs and should generally
144  * be the earliest such date. However, that is not guaranteed.
145  *
146  * For instance:
147  * ---
148  * SysTime time = parse("%d", "21");
149  * writeln(time);  // 0000-01-21T00:00:00.000000Z
150  * ---
151  */
152 SysTime parse(
153         string data,
154         string formatString,
155         immutable(TimeZone) defaultTimeZone = null,
156         bool allowTrailingData = false)
157 {
158     auto a = Interpreter(data);
159     auto res = a.parse(formatString, defaultTimeZone);
160     if (res.error)
161     {
162         throw new Exception(res.error ~ " around " ~ res.remaining);
163     }
164     if (!allowTrailingData && res.remaining.length > 0)
165     {
166         throw new Exception("trailing data: " ~ res.remaining);
167     }
168     return res.dt;
169 }
170 
171 /**
172  * Try to parse the input string according to the given pattern.
173  *
174  * Return: true to indicate success; false to indicate failure
175  */
176 bool tryParse(
177         string data,
178         const Format fmt,
179         out SysTime dt,
180         immutable(TimeZone) defaultTimeZone = null)
181 {
182     foreach (f; fmt.formatOptions)
183     {
184         if (tryParse(data, cast(string)f, dt, defaultTimeZone))
185         {
186             return true;
187         }
188     }
189     return false;
190 }
191 
192 /**
193  * Try to parse the input string according to the given pattern.
194  *
195  * Return: true to indicate success; false to indicate failure
196  */
197 bool tryParse(
198         string data,
199         string formatString,
200         out SysTime dt,
201         immutable(TimeZone) defaultTimeZone = null)
202 {
203     auto a = Interpreter(data);
204     auto res = a.parse(formatString, defaultTimeZone);
205     if (res.error)
206     {
207         return false;
208     }
209     dt = res.dt;
210     return true;
211 }
212 
213 import std.algorithm : map, cartesianProduct, joiner;
214 import std.array : array;
215 
216 /**
217   * A Format suitable for RFC1123 dates.
218   *
219   * For instance, `Sun, 02 Jan 2004 15:31:10 GMT` is a valid RFC1123 date.
220   */
221 immutable Format RFC1123FORMAT = {
222     primaryFormat: "%a, %d %b %Y %H:%M:%S %Z",
223     formatOptions:
224         // According to the spec, day-of-week is optional.
225         // Timezone can be specified in a few ways.
226         // In the wild, we have a number of variants. Like the day of week can be abbreviated or
227         // full. Likewise with the month.
228         cartesianProduct(
229                 ["%a, ", "%A, ", ""],
230                 ["%d "],
231                 ["%b", "%B"],
232                 [" %Y %H:%M:%S "],
233                 ["%Z", "%z", "%.%.%.", "%.%.", "%."]
234                 )
235         .map!(x => joiner([x.tupleof], "").array.to!string)
236         .array
237 };
238 
239 /**
240   * A Format suitable for ISO8601 dates.
241   *
242   * For instance, `2010-01-15 06:17:21.015Z` is a valid ISO8601 date.
243   */
244 immutable Format ISO8601FORMAT = {
245     primaryFormat: "%Y-%m-%dT%H:%M:%S.%f%+",
246     formatOptions: [
247         "%Y-%m-%dT%H:%M:%S.%f%+",
248         "%Y-%m-%d %H:%M:%S.%f%+",
249         "%Y-%m-%dT%H:%M:%S%+",
250         "%Y-%m-%d %H:%M:%S%+",
251         "%Y-%m-%d %H:%M:%S.%f",
252         "%Y-%m-%d %H:%M:%S",
253         "%Y-%m-%dT%H:%M:%S",
254         "%Y-%m-%d",
255     ]
256 };
257 
258 /** Parse an RFC1123 date. */
259 SysTime parseRFC1123(string data, bool allowTrailingData = false)
260 {
261     return parse(data, RFC1123FORMAT, UTC(), allowTrailingData);
262 }
263 
264 /** Produce an RFC1123 date string from a SysTime. */
265 string toRFC1123(SysTime date)
266 {
267     return format(date.toUTC(), RFC1123FORMAT);
268 }
269 
270 /** Parse an ISO8601 date. */
271 SysTime parseISO8601(string data, bool allowTrailingData = false)
272 {
273     return parse(data, ISO8601FORMAT, UTC(), allowTrailingData);
274 }
275 
276 /** Produce an ISO8601 date string from a SysTime. */
277 string toISO8601(SysTime date)
278 {
279     return format(date.toUTC(), ISO8601FORMAT);
280 }
281 
282 private:
283 
284 
285 immutable(TimeZone) utc;
286 shared static this() { utc = UTC(); }
287 
288 enum weekdayNames = [
289     "Sunday",
290     "Monday",
291     "Tuesday",
292     "Wednesday",
293     "Thursday",
294     "Friday",
295     "Saturday"
296 ];
297 
298 enum weekdayAbbrev = [
299     "Sun",
300     "Mon",
301     "Tue",
302     "Wed",
303     "Thu",
304     "Fri",
305     "Sat"
306 ];
307 
308 enum monthNames = [
309     "January",
310     "February",
311     "March",
312     "April",
313     "May",
314     "June",
315     "July",
316     "August",
317     "September",
318     "October",
319     "November",
320     "December",
321 ];
322 
323 enum monthAbbrev = [
324     "Jan",
325     "Feb",
326     "Mar",
327     "Apr",
328     "May",
329     "Jun",
330     "Jul",
331     "Aug",
332     "Sep",
333     "Oct",
334     "Nov",
335     "Dec",
336 ];
337 
338 struct Result
339 {
340     SysTime dt;
341     string error;
342     string remaining;
343     string remainingFormat;
344 }
345 
346 // TODO support wstring, dstring
347 struct Interpreter
348 {
349     this(string data)
350     {
351         this.data = data;
352     }
353     string data;
354 
355     int year;
356     int century;
357     int yearOfCentury;
358     Month month;
359     int dayOfWeek;
360     int dayOfMonth;
361     int dayOfYear;
362     int isoWeek;
363     int hour12;
364     int hour24;
365     int hour;
366     int minute;
367     int second;
368     int nanosecond;
369     int weekNumber;
370     import std.typecons : Nullable;
371     Nullable!Duration tzOffset;
372     string tzAbbreviation;
373     string tzName;
374     long epochSecond;
375     enum AMPM { AM, PM, None };
376     AMPM amPm = AMPM.None;
377     Duration fracSecs;
378 
379     import std.typecons : Rebindable;
380     Rebindable!(immutable(TimeZone)) tz;
381 
382     Result parse(string formatString, immutable(TimeZone) defaultTimeZone)
383     {
384         tz = defaultTimeZone is null ? utc : defaultTimeZone;
385         bool inPercent;
386         foreach (size_t i, dchar c; formatString)
387         {
388             if (inPercent)
389             {
390                 inPercent = false;
391                 if (!interpretFromString(c))
392                 {
393                     auto remainder = data;
394                     if (remainder.length > 15)
395                     {
396                         remainder = remainder[0..15];
397                     }
398                     return Result(SysTime.init, "unexpected value", data, formatString[i..$]);
399                 }
400             }
401             else if (c == '%')
402             {
403                 inPercent = true;
404             }
405             else
406             {
407                 // TODO non-ASCII
408                 auto b = data;
409                 bool endedEarly = false;
410                 foreach (size_t j, dchar dc; b)
411                 {
412                     data = b[j..$];
413                     if (j > 0)
414                     {
415                         endedEarly = true;
416                         break;
417                     }
418                     if (c != dc)
419                     {
420                         return Result(SysTime.init, "unexpected literal", data, formatString[j..$]);
421                     }
422                 }
423                 if (!endedEarly) data = "";
424             }
425         }
426 
427         if (!year)
428         {
429             year = century * 100 + yearOfCentury;
430         }
431         if (hour12)
432         {
433             if (amPm == AMPM.PM)
434             {
435                 hour24 = (hour12 + 12) % 24;
436             }
437             else
438             {
439                 hour24 = hour12;
440             }
441         }
442         auto dt = SysTime(DateTime(year, month, dayOfMonth, hour24, minute, second), getTimezone);
443         dt += fracSecs;
444         return Result(dt, null, data);
445     }
446 
447     private immutable(TimeZone) getTimezone()
448     {
449         if (tzOffset.isNull) return tz;
450         auto off = tzOffset.get;
451         if (off == 0.seconds) return UTC();
452         return new immutable SimpleTimeZone(off);
453     }
454 
455     bool interpretFromString(dchar c)
456     {
457         switch (c)
458         {
459             case '.':
460                 // TODO unicodes
461                 if (data.length >= 1)
462                 {
463                     data = data[1..$];
464                     return true;
465                 }
466                 return false;
467             case 'a':
468                 foreach (i, m; weekdayAbbrev)
469                 {
470                     if (data.startsWith(m))
471                     {
472                         data = data[m.length .. $];
473                         return true;
474                     }
475                 }
476                 return false;
477             case 'A':
478                 foreach (i, m; weekdayNames)
479                 {
480                     if (data.startsWith(m))
481                     {
482                         data = data[m.length .. $];
483                         return true;
484                     }
485                 }
486                 return false;
487             case 'b':
488                 foreach (i, m; monthAbbrev)
489                 {
490                     if (data.startsWith(m))
491                     {
492                         month = cast(Month)(i + 1);
493                         data = data[m.length .. $];
494                         return true;
495                     }
496                 }
497                 return false;
498             case 'B':
499                 foreach (i, m; monthNames)
500                 {
501                     if (data.startsWith(m))
502                     {
503                         month = cast(Month)(i + 1);
504                         data = data[m.length .. $];
505                         return true;
506                     }
507                 }
508                 return false;
509             case 'C':
510                 return parseInt!(x => century = x)(data);
511             case 'd':
512                 return parseInt!(x => dayOfMonth = x)(data);
513             case 'e':
514                 return parseInt!(x => dayOfMonth = x)(data);
515             case 'g':
516                 return parseInt!(x => fracSecs = x.msecs)(data);
517             case 'G':
518                 return parseInt!(x => fracSecs = x.nsecs)(data);
519             case 'f':
520                 size_t end = data.length;
521                 foreach (i, cc; data)
522                 {
523                     if ('0' > cc || '9' < cc)
524                     {
525                         end = i;
526                         break;
527                     }
528                 }
529                 auto fss = data[0..end];
530                 data = data[end..$];
531                 if (fss.length == 0)
532                 {
533                     return false;
534                 }
535                 auto fs = fss.to!ulong;
536                 while (end < 7)
537                 {
538                     end++;
539                     fs *= 10;
540                 }
541                 while (end > 7)
542                 {
543                     end--;
544                     fs /= 10;
545                 }
546                 this.fracSecs = fs.hnsecs;
547                 return true;
548             case 'F':
549                 auto dash1 = data.indexOf('-');
550                 if (dash1 <= 0) return false;
551                 if (dash1 >= data.length - 1) return false;
552                 auto yearStr = data[0..dash1];
553                 auto year = yearStr.to!int;
554                 data = data[dash1 + 1 .. $];
555 
556                 if (data.length < 5)
557                 {
558                     // Month is 2 digits; day is 2 digits; dash between
559                     return false;
560                 }
561                 if (data[2] != '-')
562                 {
563                     return false;
564                 }
565                 if (!parseInt!(x => month = cast(Month)x)(data)) return false;
566                 if (!data.startsWith("-")) return false;
567                 data = data[1..$];
568                 return parseInt!(x => dayOfMonth = x)(data);
569             case 'H':
570             case 'k':
571                 auto h = parseInt!(x => hour24 = x)(data);
572                 return h;
573             case 'h':
574             case 'I':
575             case 'l':
576                 return parseInt!(x => hour12 = x)(data);
577             case 'j':
578                 return parseInt!(x => dayOfYear = x, 3)(data);
579             case 'm':
580                 return parseInt!(x => month = cast(Month)x)(data);
581             case 'M':
582                 return parseInt!(x => minute = x)(data);
583             case 'p':
584                 if (data.startsWith("AM"))
585                 {
586                     amPm = AMPM.AM;
587                 }
588                 else if (data.startsWith("PM"))
589                 {
590                     amPm = AMPM.PM;
591                 }
592                 else
593                 {
594                     return false;
595                 }
596                 return true;
597             case 'P':
598                 if (data.startsWith("am"))
599                 {
600                     amPm = AMPM.AM;
601                 }
602                 else if (data.startsWith("pm"))
603                 {
604                     amPm = AMPM.PM;
605                 }
606                 else
607                 {
608                     return false;
609                 }
610                 return true;
611             case 'r':
612                 return interpretFromString('I') &&
613                     pop(':') &&
614                     interpretFromString('M') &&
615                     pop(':') &&
616                     interpretFromString('S') &&
617                     pop(' ') &&
618                     interpretFromString('p');
619             case 'R':
620                 return interpretFromString('H') &&
621                     pop(':') &&
622                     interpretFromString('M');
623             case 's':
624                 size_t end = 0;
625                 foreach (i2, c2; data)
626                 {
627                     if (c2 < '0' || c2 > '9')
628                     {
629                         end = cast()i2;
630                         break;
631                     }
632                 }
633                 if (end == 0) return false;
634                 epochSecond = data[0..end].to!int;
635                 data = data[end..$];
636                 return true;
637             case 'S':
638                 return parseInt!(x => second = x)(data);
639             case 'T':
640                 return interpretFromString('H') &&
641                     pop(':') &&
642                     interpretFromString('M') &&
643                     pop(':') &&
644                     interpretFromString('S');
645             case 'u':
646                 return parseInt!(x => dayOfWeek = cast(DayOfWeek)(x % 7))(data);
647             case 'V':
648                 return parseInt!(x => isoWeek = x)(data);
649             case 'y':
650                 return parseInt!(x => yearOfCentury = x)(data);
651             case 'Y':
652                 size_t end = 0;
653                 foreach (i2, c2; data)
654                 {
655                     if (c2 < '0' || c2 > '9')
656                     {
657                         end = i2;
658                         break;
659                     }
660                 }
661                 if (end == 0) return false;
662                 year = data[0..end].to!int;
663                 data = data[end..$];
664                 return true;
665             case 'z':
666             case '+':
667                 if (pop('Z'))  // for ISO8601
668                 {
669                     tzOffset = 0.seconds;
670                     return true;
671                 }
672 
673                 int sign = 0;
674                 if (pop('-'))
675                 {
676                     sign = -1;
677                 }
678                 else if (pop('+'))
679                 {
680                     sign = 1;
681                 }
682                 else
683                 {
684                     return false;
685                 }
686                 int hour, minute;
687                 parseInt!(x => hour = x)(data);
688                 parseInt!(x => minute = x)(data);
689                 tzOffset = dur!"minutes"(sign * (minute + 60 * hour));
690                 return true;
691             case 'Z':
692                 foreach (i, v; canonicalZones)
693                 {
694                     if (data.startsWith(v.name))
695                     {
696                         tz = canonicalZones[i].zone;
697                         break;
698                     }
699                 }
700                 return false;
701             default:
702                 throw new Exception("unrecognized control character %" ~ c.to!string);
703         }
704     }
705 
706     bool pop(dchar c)
707     {
708         if (data.startsWith(c))
709         {
710             data = data[c.codeLength!char .. $];
711             return true;
712         }
713         return false;
714     }
715 }
716 
717 struct CanonicalZone
718 {
719     string name;
720     immutable(TimeZone) zone;
721 }
722 // array so we can control iteration order -- longest first
723 CanonicalZone[] canonicalZones;
724 shared static this()
725 {
726     version (Posix)
727     {
728         auto utc = PosixTimeZone.getTimeZone("Etc/UTC");
729         auto est = PosixTimeZone.getTimeZone("America/New_York");
730         auto cst = PosixTimeZone.getTimeZone("America/Boise");
731         auto mst = PosixTimeZone.getTimeZone("America/Chicago");
732         auto pst = PosixTimeZone.getTimeZone("America/Los_Angeles");
733     }
734     else
735     {
736         auto utc = WindowsTimeZone.getTimeZone("UTC");
737         auto est = WindowsTimeZone.getTimeZone("Eastern Standard Time");
738         auto cst = WindowsTimeZone.getTimeZone("Central Standard Time");
739         auto mst = WindowsTimeZone.getTimeZone("Mountain Standard Time");
740         auto pst = WindowsTimeZone.getTimeZone("Pacific Standard Time");
741     }
742     canonicalZones =
743     [
744         // TODO ensure the MDT style variants prefer the daylight time version of the date
745         CanonicalZone("UTC", utc),
746         CanonicalZone("UT", utc),
747         CanonicalZone("Z", utc),
748         CanonicalZone("GMT", utc),
749         CanonicalZone("EST", est),
750         CanonicalZone("EDT", est),
751         CanonicalZone("CST", cst),
752         CanonicalZone("CDT", cst),
753         CanonicalZone("MST", mst),
754         CanonicalZone("MDT", mst),
755         CanonicalZone("PST", pst),
756         CanonicalZone("PDT", pst),
757     ];
758 }
759 
760 bool parseInt(alias setter, int length = 2)(ref string data)
761 {
762     if (data.length < length)
763     {
764         return false;
765     }
766     auto c = data[0..length].strip;
767     data = data[length..$];
768     int v;
769     try
770     {
771         v = c.to!int;
772     }
773     catch (ConvException e)
774     {
775         return false;
776     }
777     cast(void)setter(c.to!int);
778     return true;
779 }
780 
781 void interpretIntoString(ref Appender!string ap, SysTime dt, char c)
782 {
783     static import std.format;
784     switch (c)
785     {
786         case 'a':
787             ap ~= weekdayAbbrev[cast(size_t)dt.dayOfWeek];
788             return;
789         case 'A':
790             ap ~= weekdayNames[cast(size_t)dt.dayOfWeek];
791             return;
792         case 'b':
793             ap ~= monthAbbrev[cast(size_t)dt.month - 1];
794             return;
795         case 'B':
796             ap ~= monthNames[cast(size_t)dt.month - 1];
797             return;
798         case 'C':
799             ap ~= (dt.year / 100).to!string;
800             return;
801         case 'd':
802             auto s = dt.day.to!string;
803             if (s.length == 1)
804             {
805                 ap ~= "0";
806             }
807             ap ~= s;
808             return;
809         case 'e':
810             auto s = dt.day.to!string;
811             if (s.length == 1)
812             {
813                 ap ~= " ";
814             }
815             ap ~= s;
816             return;
817         case 'f':
818             ap ~= std.format.format("%06d", dt.fracSecs.total!"usecs");
819             return;
820         case 'g':
821             ap ~= std.format.format("%03d", dt.fracSecs.total!"msecs");
822             return;
823         case 'G':
824             ap ~= std.format.format("%09d", dt.fracSecs.total!"nsecs");
825             return;
826         case 'F':
827             interpretIntoString(ap, dt, 'Y');
828             ap ~= '-';
829             interpretIntoString(ap, dt, 'm');
830             ap ~= '-';
831             interpretIntoString(ap, dt, 'd');
832             return;
833         case 'h':
834         case 'I':
835             auto h = dt.hour;
836             if (h == 0)
837             {
838                 h = 12;
839             }
840             else if (h > 12)
841             {
842                 h -= 12;
843             }
844             ap.pad(h.to!string, '0', 2);
845             return;
846         case 'H':
847             ap.pad(dt.hour.to!string, '0', 2);
848             return;
849         case 'j':
850             ap.pad(dt.dayOfYear.to!string, '0', 3);
851             return;
852         case 'k':
853             ap.pad(dt.hour.to!string, ' ', 2);
854             return;
855         case 'l':
856             auto h = dt.hour;
857             if (h == 0)
858             {
859                 h = 12;
860             }
861             else if (h > 12)
862             {
863                 h -= 12;
864             }
865             ap.pad(h.to!string, ' ', 2);
866             return;
867         case 'm':
868             uint m = cast(uint)dt.month;
869             ap.pad(m.to!string, '0', 2);
870             return;
871         case 'M':
872             ap.pad(dt.minute.to!string, '0', 2);
873             return;
874         case 'p':
875             if (dt.hour >= 12)
876             {
877                 ap ~= "PM";
878             }
879             else
880             {
881                 ap ~= "AM";
882             }
883             return;
884         case 'P':
885             if (dt.hour >= 12)
886             {
887                 ap ~= "pm";
888             }
889             else
890             {
891                 ap ~= "am";
892             }
893             return;
894         case 'r':
895             interpretIntoString(ap, dt, 'I');
896             ap ~= ':';
897             interpretIntoString(ap, dt, 'M');
898             ap ~= ':';
899             interpretIntoString(ap, dt, 'S');
900             ap ~= ' ';
901             interpretIntoString(ap, dt, 'p');
902             return;
903         case 'R':
904             interpretIntoString(ap, dt, 'H');
905             ap ~= ':';
906             interpretIntoString(ap, dt, 'M');
907             return;
908         case 's':
909             auto delta = dt - SysTime(DateTime(1970, 1, 1), UTC());
910             ap ~= delta.total!"seconds"().to!string;
911             return;
912         case 'S':
913             ap.pad(dt.second.to!string, '0', 2);
914             return;
915         case 'T':
916             interpretIntoString(ap, dt, 'H');
917             ap ~= ':';
918             interpretIntoString(ap, dt, 'M');
919             ap ~= ':';
920             interpretIntoString(ap, dt, 'S');
921             return;
922         case 'u':
923             auto dow = cast(uint)dt.dayOfWeek;
924             if (dow == 0) dow = 7;
925             ap ~= dow.to!string;
926             return;
927         case 'w':
928             ap ~= (cast(uint)dt.dayOfWeek).to!string;
929             return;
930         case 'y':
931             ap.pad((dt.year % 100).to!string, '0', 2);
932             return;
933         case 'Y':
934             ap.pad(dt.year.to!string, '0', 4);
935             return;
936         case '+':
937             if (dt.utcOffset == dur!"seconds"(0))
938             {
939                 ap ~= 'Z';
940                 return;
941             }
942             // If it's not UTC, format as +HHMM
943             goto case;
944         case 'z':
945             import std.math : abs;
946             auto d = dt.utcOffset;
947             if (d < dur!"seconds"(0))
948             {
949                 ap ~= '-';
950             }
951             else
952             {
953                 ap ~= '+';
954             }
955             auto minutes = abs(d.total!"minutes");
956             ap.pad((minutes / 60).to!string, '0', 2);
957             ap.pad((minutes % 60).to!string, '0', 2);
958             return;
959         case 'Z':
960             if (dt.timezone is null || dt.timezone.isUTC())
961             {
962                 ap ~= 'Z';
963             }
964             if (dt.dstInEffect)
965             {
966                 ap ~= dt.timezone.stdName;
967             }
968             else
969             {
970                 ap ~= dt.timezone.dstName;
971             }
972             return;
973         case '%':
974             ap ~= '%';
975             return;
976         default:
977             throw new Exception("format element %" ~ c ~ " not recognized");
978     }
979 }
980 
981 private bool isUTC(const TimeZone zone) @trusted
982 {
983     return zone == UTC();
984 }
985 
986 void pad(ref Appender!string ap, string s, char pad, uint length)
987 {
988     if (s.length >= length)
989     {
990         ap ~= s;
991         return;
992     }
993     for (uint i = 0; i < length - s.length; i++)
994     {
995         ap ~= pad;
996     }
997     ap ~= s;
998 }
999 
1000 unittest
1001 {
1002     import std.stdio;
1003     auto dt = SysTime(
1004             DateTime(2017, 5, 3, 14, 31, 57),
1005             UTC());
1006     auto isoishFmt = "%Y-%m-%d %H:%M:%S %z";
1007     auto isoish = dt.format(isoishFmt);
1008     assert(isoish == "2017-05-03 14:31:57 +0000", isoish);
1009     auto parsed = isoish.parse(isoishFmt);
1010     assert(parsed.timezone !is null);
1011     assert(parsed.timezone.isUTC());
1012     assert(parsed == dt, parsed.format(isoishFmt));
1013 }
1014 
1015 unittest
1016 {
1017     auto isoishFmt = "%Y-%m-%d %H:%M:%S %z";
1018     auto parsed = "2017-05-03 14:31:57 +0000".parse(isoishFmt, new immutable SimpleTimeZone(6.hours));
1019     assert(parsed.timezone !is null);
1020     assert(parsed.timezone.isUTC());
1021 }
1022 
1023 unittest
1024 {
1025     auto isoishFmt = "%Y-%m-%d %H:%M:%S";
1026     auto parsed = "2017-05-03 14:31:57".parse(isoishFmt, new immutable SimpleTimeZone(-6.hours));
1027     assert(parsed.timezone !is null);
1028     assert(!parsed.timezone.isUTC());
1029     assert(parsed.timezone.utcOffsetAt(parsed.stdTime) == -6.hours);
1030 }
1031 
1032 unittest
1033 {
1034     SysTime st;
1035     assert(tryParse("2013-10-09T14:56:33.050-06:00", ISO8601FORMAT, st, UTC()));
1036     assert(st.year == 2013);
1037     assert(st.month == 10);
1038     assert(st.day == 9);
1039     assert(st.hour == 14, st.hour.to!string);
1040     assert(st.minute == 56);
1041     assert(st.second == 33);
1042     assert(st.fracSecs == 50.msecs);
1043     assert(st.timezone !is null);
1044     assert(!st.timezone.isUTC());
1045     assert(st.timezone.utcOffsetAt(st.stdTime) == -6.hours);
1046 }
1047 
1048 unittest
1049 {
1050     import std.stdio;
1051     auto dt = SysTime(
1052             DateTime(2017, 5, 3, 14, 31, 57),
1053             UTC());
1054     auto isoishFmt = ISO8601FORMAT;
1055     auto isoish = "2017-05-03T14:31:57.000000Z";
1056     auto parsed = isoish.parse(isoishFmt);
1057     assert(parsed.timezone !is null);
1058     assert(parsed.timezone.isUTC());
1059     assert(parsed == dt, parsed.format(isoishFmt));
1060 }
1061 
1062 unittest
1063 {
1064     import std.stdio;
1065     auto dt = SysTime(
1066             DateTime(2017, 5, 3, 14, 31, 57),
1067             UTC()) + 10.msecs;
1068     assert(dt.fracSecs == 10.msecs, "can't add dates");
1069     auto isoishFmt = ISO8601FORMAT;
1070     auto isoish = "2017-05-03T14:31:57.010000Z";
1071     auto parsed = isoish.parse(isoishFmt);
1072     assert(parsed.fracSecs == 10.msecs, "can't parse millis");
1073     assert(parsed.timezone !is null);
1074     assert(parsed.timezone.isUTC());
1075     assert(parsed == dt, parsed.format(isoishFmt));
1076 
1077 }
1078 
1079 unittest
1080 {
1081     auto formatted = "Thu, 04 Sep 2014 06:42:22 GMT";
1082     auto dt = parseRFC1123(formatted);
1083     assert(dt == SysTime(DateTime(2014, 9, 4, 6, 42, 22), UTC()), dt.toISOString());
1084 }
1085 
1086 unittest
1087 {
1088     // RFC1123 dates
1089     /*
1090     // Uncomment to list out the generated format strings (for debugging)
1091     foreach (fmt; exhaustiveDateFormat.formatOptions)
1092     {
1093         import std.stdio : writeln;
1094         writeln(fmt);
1095     }
1096     */
1097     void test(string date) @safe
1098     {
1099         SysTime st;
1100         assert(tryParse(date, RFC1123FORMAT, st, UTC()),
1101                 "failed to parse date " ~ date);
1102         assert(st.year == 2017);
1103         assert(st.month == 6);
1104         assert(st.day == 11);
1105         assert(st.hour == 22);
1106         assert(st.minute == 15);
1107         assert(st.second == 47);
1108         assert(st.timezone.isUTC());
1109     }
1110 
1111     // RFC1123-ish
1112     test("Sun, 11 Jun 2017 22:15:47 UTC");
1113     test("11 Jun 2017 22:15:47 UTC");
1114     test("Sunday, 11 Jun 2017 22:15:47 UTC");
1115     test("Sun, 11 June 2017 22:15:47 UTC");
1116     test("11 June 2017 22:15:47 UTC");
1117     test("Sunday, 11 June 2017 22:15:47 UTC");
1118     test("Sun, 11 Jun 2017 22:15:47 +0000");
1119     test("11 Jun 2017 22:15:47 +0000");
1120     test("Sunday, 11 Jun 2017 22:15:47 +0000");
1121     test("Sun, 11 June 2017 22:15:47 +0000");
1122     test("11 June 2017 22:15:47 +0000");
1123     test("Sunday, 11 June 2017 22:15:47 +0000");
1124 }
1125 
1126 unittest
1127 {
1128     SysTime st;
1129     assert(!tryParse("", RFC1123FORMAT, st, UTC()));
1130 }
1131 
1132 unittest
1133 {
1134     // Thanks to Ha Le at https://github.com/haqle314 for catching this
1135     SysTime d1 = SysTime(DateTime(2019, 1, 1), UTC());
1136     assert(d1.format("%b") == "Jan");
1137     assert(d1.format("%B") == "January");
1138 }