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