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 fs = data[0..end].to!ulong;
519                 data = data[end..$];
520                 while (end < 7)
521                 {
522                     end++;
523                     fs *= 10;
524                 }
525                 while (end > 7)
526                 {
527                     end--;
528                     fs /= 10;
529                 }
530                 this.fracSecs = fs.hnsecs;
531                 return true;
532             case 'F':
533                 auto dash1 = data.indexOf('-');
534                 if (dash1 <= 0) return false;
535                 if (dash1 >= data.length - 1) return false;
536                 auto yearStr = data[0..dash1];
537                 auto year = yearStr.to!int;
538                 data = data[dash1 + 1 .. $];
539 
540                 if (data.length < 5)
541                 {
542                     // Month is 2 digits; day is 2 digits; dash between
543                     return false;
544                 }
545                 if (data[2] != '-')
546                 {
547                     return false;
548                 }
549                 if (!parseInt!(x => month = cast(Month)x)(data)) return false;
550                 if (!data.startsWith("-")) return false;
551                 data = data[1..$];
552                 return parseInt!(x => dayOfMonth = x)(data);
553             case 'H':
554             case 'k':
555                 auto h = parseInt!(x => hour24 = x)(data);
556                 return h;
557             case 'h':
558             case 'I':
559             case 'l':
560                 return parseInt!(x => hour12 = x)(data);
561             case 'j':
562                 return parseInt!(x => dayOfYear = x, 3)(data);
563             case 'm':
564                 return parseInt!(x => month = cast(Month)x)(data);
565             case 'M':
566                 return parseInt!(x => minute = x)(data);
567             case 'p':
568                 if (data.startsWith("AM"))
569                 {
570                     amPm = AMPM.AM;
571                 }
572                 else if (data.startsWith("PM"))
573                 {
574                     amPm = AMPM.PM;
575                 }
576                 else
577                 {
578                     return false;
579                 }
580                 return true;
581             case 'P':
582                 if (data.startsWith("am"))
583                 {
584                     amPm = AMPM.AM;
585                 }
586                 else if (data.startsWith("pm"))
587                 {
588                     amPm = AMPM.PM;
589                 }
590                 else
591                 {
592                     return false;
593                 }
594                 return true;
595             case 'r':
596                 return interpretFromString('I') &&
597                     pop(':') &&
598                     interpretFromString('M') &&
599                     pop(':') &&
600                     interpretFromString('S') &&
601                     pop(' ') &&
602                     interpretFromString('p');
603             case 'R':
604                 return interpretFromString('H') &&
605                     pop(':') &&
606                     interpretFromString('M');
607             case 's':
608                 size_t end = 0;
609                 foreach (i2, c2; data)
610                 {
611                     if (c2 < '0' || c2 > '9')
612                     {
613                         end = cast()i2;
614                         break;
615                     }
616                 }
617                 if (end == 0) return false;
618                 epochSecond = data[0..end].to!int;
619                 data = data[end..$];
620                 return true;
621             case 'S':
622                 return parseInt!(x => second = x)(data);
623             case 'T':
624                 return interpretFromString('H') &&
625                     pop(':') &&
626                     interpretFromString('M') &&
627                     pop(':') &&
628                     interpretFromString('S');
629             case 'u':
630                 return parseInt!(x => dayOfWeek = cast(DayOfWeek)(x % 7))(data);
631             case 'V':
632                 return parseInt!(x => isoWeek = x)(data);
633             case 'y':
634                 return parseInt!(x => yearOfCentury = x)(data);
635             case 'Y':
636                 size_t end = 0;
637                 foreach (i2, c2; data)
638                 {
639                     if (c2 < '0' || c2 > '9')
640                     {
641                         end = i2;
642                         break;
643                     }
644                 }
645                 if (end == 0) return false;
646                 year = data[0..end].to!int;
647                 data = data[end..$];
648                 return true;
649             case 'z':
650             case '+':
651                 if (pop('Z'))  // for ISO8601
652                 {
653                     tzOffset = 0.seconds;
654                     return true;
655                 }
656 
657                 int sign = 0;
658                 if (pop('-'))
659                 {
660                     sign = -1;
661                 }
662                 else if (pop('+'))
663                 {
664                     sign = 1;
665                 }
666                 else
667                 {
668                     return false;
669                 }
670                 int hour, minute;
671                 parseInt!(x => hour = x)(data);
672                 parseInt!(x => minute = x)(data);
673                 tzOffset = dur!"minutes"(sign * (minute + 60 * hour));
674                 return true;
675             case 'Z':
676                 foreach (i, v; canonicalZones)
677                 {
678                     if (data.startsWith(v.name))
679                     {
680                         tz = &canonicalZones[i].zone;
681                         break;
682                     }
683                 }
684                 return false;
685             default:
686                 throw new Exception("unrecognized control character %" ~ c.to!string);
687         }
688     }
689 
690     bool pop(dchar c)
691     {
692         if (data.startsWith(c))
693         {
694             data = data[c.codeLength!char .. $];
695             return true;
696         }
697         return false;
698     }
699 }
700 
701 struct CanonicalZone
702 {
703     string name;
704     immutable(TimeZone) zone;
705 }
706 // array so we can control iteration order -- longest first
707 CanonicalZone[] canonicalZones;
708 shared static this()
709 {
710     version (Posix)
711     {
712         auto utc = PosixTimeZone.getTimeZone("Etc/UTC");
713         auto est = PosixTimeZone.getTimeZone("America/New_York");
714         auto cst = PosixTimeZone.getTimeZone("America/Boise");
715         auto mst = PosixTimeZone.getTimeZone("America/Chicago");
716         auto pst = PosixTimeZone.getTimeZone("America/Los_Angeles");
717     }
718     else
719     {
720         auto utc = WindowsTimeZone.getTimeZone("UTC");
721         auto est = WindowsTimeZone.getTimeZone("EST");
722         auto cst = WindowsTimeZone.getTimeZone("CST");
723         auto mst = WindowsTimeZone.getTimeZone("MST");
724         auto pst = WindowsTimeZone.getTimeZone("PST");
725     }
726     canonicalZones =
727     [
728         // TODO ensure the MDT style variants prefer the daylight time version of the date
729         CanonicalZone("UTC", utc),
730         CanonicalZone("UT", utc),
731         CanonicalZone("Z", utc),
732         CanonicalZone("GMT", utc),
733         CanonicalZone("EST", est),
734         CanonicalZone("EDT", est),
735         CanonicalZone("CST", cst),
736         CanonicalZone("CDT", cst),
737         CanonicalZone("MST", mst),
738         CanonicalZone("MDT", mst),
739         CanonicalZone("PST", pst),
740         CanonicalZone("PDT", pst),
741     ];
742 }
743 
744 bool parseInt(alias setter, int length = 2)(ref string data)
745 {
746     if (data.length < length)
747     {
748         return false;
749     }
750     auto c = data[0..length].strip;
751     data = data[length..$];
752     int v;
753     try
754     {
755         v = c.to!int;
756     }
757     catch (ConvException e)
758     {
759         return false;
760     }
761     cast(void)setter(c.to!int);
762     return true;
763 }
764 
765 void interpretIntoString(ref Appender!string ap, SysTime dt, char c)
766 {
767     static import std.format;
768     switch (c)
769     {
770         case 'a':
771             ap ~= weekdayAbbrev[cast(size_t)dt.dayOfWeek];
772             return;
773         case 'A':
774             ap ~= weekdayNames[cast(size_t)dt.dayOfWeek];
775             return;
776         case 'b':
777             ap ~= monthAbbrev[cast(size_t)dt.month];
778             return;
779         case 'B':
780             ap ~= monthNames[cast(size_t)dt.month];
781             return;
782         case 'C':
783             ap ~= (dt.year / 100).to!string;
784             return;
785         case 'd':
786             auto s = dt.day.to!string;
787             if (s.length == 1)
788             {
789                 ap ~= "0";
790             }
791             ap ~= s;
792             return;
793         case 'e':
794             auto s = dt.day.to!string;
795             if (s.length == 1)
796             {
797                 ap ~= " ";
798             }
799             ap ~= s;
800             return;
801         case 'f':
802             ap ~= std.format.format("%06d", dt.fracSecs.total!"usecs");
803             return;
804         case 'g':
805             ap ~= std.format.format("%03d", dt.fracSecs.total!"msecs");
806             return;
807         case 'G':
808             ap ~= std.format.format("%09d", dt.fracSecs.total!"nsecs");
809             return;
810         case 'F':
811             interpretIntoString(ap, dt, 'Y');
812             ap ~= '-';
813             interpretIntoString(ap, dt, 'm');
814             ap ~= '-';
815             interpretIntoString(ap, dt, 'd');
816             return;
817         case 'h':
818         case 'I':
819             auto h = dt.hour;
820             if (h == 0)
821             {
822                 h = 12;
823             }
824             else if (h > 12)
825             {
826                 h -= 12;
827             }
828             ap.pad(h.to!string, '0', 2);
829             return;
830         case 'H':
831             ap.pad(dt.hour.to!string, '0', 2);
832             return;
833         case 'j':
834             ap.pad(dt.dayOfYear.to!string, '0', 3);
835             return;
836         case 'k':
837             ap.pad(dt.hour.to!string, ' ', 2);
838             return;
839         case 'l':
840             auto h = dt.hour;
841             if (h == 0)
842             {
843                 h = 12;
844             }
845             else if (h > 12)
846             {
847                 h -= 12;
848             }
849             ap.pad(h.to!string, ' ', 2);
850             return;
851         case 'm':
852             uint m = cast(uint)dt.month;
853             ap.pad(m.to!string, '0', 2);
854             return;
855         case 'M':
856             ap.pad(dt.minute.to!string, '0', 2);
857             return;
858         case 'p':
859             if (dt.hour >= 12)
860             {
861                 ap ~= "PM";
862             }
863             else
864             {
865                 ap ~= "AM";
866             }
867             return;
868         case 'P':
869             if (dt.hour >= 12)
870             {
871                 ap ~= "pm";
872             }
873             else
874             {
875                 ap ~= "am";
876             }
877             return;
878         case 'r':
879             interpretIntoString(ap, dt, 'I');
880             ap ~= ':';
881             interpretIntoString(ap, dt, 'M');
882             ap ~= ':';
883             interpretIntoString(ap, dt, 'S');
884             ap ~= ' ';
885             interpretIntoString(ap, dt, 'p');
886             return;
887         case 'R':
888             interpretIntoString(ap, dt, 'H');
889             ap ~= ':';
890             interpretIntoString(ap, dt, 'M');
891             return;
892         case 's':
893             auto delta = dt - SysTime(DateTime(1970, 1, 1), UTC());
894             ap ~= delta.total!"seconds"().to!string;
895             return;
896         case 'S':
897             ap.pad(dt.second.to!string, '0', 2);
898             return;
899         case 'T':
900             interpretIntoString(ap, dt, 'H');
901             ap ~= ':';
902             interpretIntoString(ap, dt, 'M');
903             ap ~= ':';
904             interpretIntoString(ap, dt, 'S');
905             return;
906         case 'u':
907             auto dow = cast(uint)dt.dayOfWeek;
908             if (dow == 0) dow = 7;
909             ap ~= dow.to!string;
910             return;
911         case 'w':
912             ap ~= (cast(uint)dt.dayOfWeek).to!string;
913             return;
914         case 'y':
915             ap.pad((dt.year % 100).to!string, '0', 2);
916             return;
917         case 'Y':
918             ap.pad(dt.year.to!string, '0', 4);
919             return;
920         case '+':
921             if (dt.utcOffset == dur!"seconds"(0))
922             {
923                 ap ~= 'Z';
924                 return;
925             }
926             // If it's not UTC, format as +HHMM
927             goto case;
928         case 'z':
929             import std.math : abs;
930             auto d = dt.utcOffset;
931             if (d < dur!"seconds"(0))
932             {
933                 ap ~= '-';
934             }
935             else
936             {
937                 ap ~= '+';
938             }
939             auto minutes = abs(d.total!"minutes");
940             ap.pad((minutes / 60).to!string, '0', 2);
941             ap.pad((minutes % 60).to!string, '0', 2);
942             return;
943         case 'Z':
944             if (dt.timezone is null || dt.timezone == UTC())
945             {
946                 ap ~= 'Z';
947             }
948             if (dt.dstInEffect)
949             {
950                 ap ~= dt.timezone.stdName;
951             }
952             else
953             {
954                 ap ~= dt.timezone.dstName;
955             }
956             return;
957         case '%':
958             ap ~= '%';
959             return;
960         default:
961             throw new Exception("format element %" ~ c ~ " not recognized");
962     }
963 }
964 
965 void pad(ref Appender!string ap, string s, char pad, uint length)
966 {
967     if (s.length >= length)
968     {
969         ap ~= s;
970         return;
971     }
972     for (uint i = 0; i < length - s.length; i++)
973     {
974         ap ~= pad;
975     }
976     ap ~= s;
977 }
978 
979 unittest
980 {
981     import std.stdio;
982     auto dt = SysTime(
983             DateTime(2017, 5, 3, 14, 31, 57),
984             UTC());
985     auto isoishFmt = "%Y-%m-%d %H:%M:%S %z";
986     auto isoish = dt.format(isoishFmt);
987     assert(isoish == "2017-05-03 14:31:57 +0000", isoish);
988     auto parsed = isoish.parse(isoishFmt);
989     assert(parsed.timezone !is null);
990     assert(parsed.timezone == UTC());
991     assert(parsed == dt, parsed.format(isoishFmt));
992 }
993 
994 unittest
995 {
996     SysTime st;
997     assert(tryParse("2013-10-09T14:56:33.050-06:00", ISO8601FORMAT, st, UTC()));
998     assert(st.year == 2013);
999     assert(st.month == 10);
1000     assert(st.day == 9);
1001     assert(st.hour == 14, st.hour.to!string);
1002     assert(st.minute == 56);
1003     assert(st.second == 33);
1004     assert(st.fracSecs == 50.msecs);
1005     assert(st.timezone !is null);
1006     assert(st.timezone != UTC());
1007     assert(st.timezone.utcOffsetAt(st.stdTime) == -6.hours);
1008 }
1009 
1010 unittest
1011 {
1012     import std.stdio;
1013     auto dt = SysTime(
1014             DateTime(2017, 5, 3, 14, 31, 57),
1015             UTC());
1016     auto isoishFmt = ISO8601FORMAT;
1017     auto isoish = "2017-05-03T14:31:57.000000Z";
1018     auto parsed = isoish.parse(isoishFmt);
1019     assert(parsed.timezone !is null);
1020     assert(parsed.timezone == UTC());
1021     assert(parsed == dt, parsed.format(isoishFmt));
1022 }
1023 
1024 unittest
1025 {
1026     import std.stdio;
1027     auto dt = SysTime(
1028             DateTime(2017, 5, 3, 14, 31, 57),
1029             UTC()) + 10.msecs;
1030     assert(dt.fracSecs == 10.msecs, "can't add dates");
1031     auto isoishFmt = ISO8601FORMAT;
1032     auto isoish = "2017-05-03T14:31:57.010000Z";
1033     auto parsed = isoish.parse(isoishFmt);
1034     assert(parsed.fracSecs == 10.msecs, "can't parse millis");
1035     assert(parsed.timezone !is null);
1036     assert(parsed.timezone == UTC());
1037     assert(parsed == dt, parsed.format(isoishFmt));
1038 
1039 }
1040 
1041 unittest
1042 {
1043     auto formatted = "Thu, 04 Sep 2014 06:42:22 GMT";
1044     auto dt = parseRFC1123(formatted);
1045     assert(dt == SysTime(DateTime(2014, 9, 4, 6, 42, 22), UTC()), dt.toISOString());
1046 }
1047 
1048 unittest
1049 {
1050     // RFC1123 dates
1051     /*
1052     // Uncomment to list out the generated format strings (for debugging)
1053     foreach (fmt; exhaustiveDateFormat.formatOptions)
1054     {
1055         import std.stdio : writeln;
1056         writeln(fmt);
1057     }
1058     */
1059     void test(string date)
1060     {
1061         SysTime st;
1062         assert(tryParse(date, RFC1123FORMAT, st, UTC()),
1063                 "failed to parse date " ~ date);
1064         assert(st.year == 2017);
1065         assert(st.month == 6);
1066         assert(st.day == 11);
1067         assert(st.hour == 22);
1068         assert(st.minute == 15);
1069         assert(st.second == 47);
1070         assert(st.timezone == UTC());
1071     }
1072 
1073     // RFC1123-ish
1074     test("Sun, 11 Jun 2017 22:15:47 UTC");
1075     test("11 Jun 2017 22:15:47 UTC");
1076     test("Sunday, 11 Jun 2017 22:15:47 UTC");
1077     test("Sun, 11 June 2017 22:15:47 UTC");
1078     test("11 June 2017 22:15:47 UTC");
1079     test("Sunday, 11 June 2017 22:15:47 UTC");
1080     test("Sun, 11 Jun 2017 22:15:47 +0000");
1081     test("11 Jun 2017 22:15:47 +0000");
1082     test("Sunday, 11 Jun 2017 22:15:47 +0000");
1083     test("Sun, 11 June 2017 22:15:47 +0000");
1084     test("11 June 2017 22:15:47 +0000");
1085     test("Sunday, 11 June 2017 22:15:47 +0000");
1086 }
1087 
1088 unittest
1089 {
1090     SysTime st;
1091     assert(!tryParse("", RFC1123FORMAT, st, UTC()));
1092 }