1 /**
2   * This framework provides parser of cron expressions and evaluator
3   * of next date and time of triggering parsed cron expressions.
4   *
5   * Cron expression consists of 6 required fields separated by white space
6   *
7   * Supported fields:
8   *
9   * Field           Allowed values      Special charachters
10   *
11   * Seconds         0-59                , - * /
12   * Minutes         0-59                , - * /
13   * Hours           0-23                , - * /
14   * Day-of-month    1-31                , - * / ?
15   * Month           1-12 or JAN-DEC     , - * /
16   * Day-of-week     1-7 or MON-SUN      , - * / ?
17   *
18   * Months names: JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC
19   * Day-of-weeks names: MON,TUE,WED,THU,FRI,SAT,SUN
20   *
21   * Charachters:
22   *
23   * Asterisk (*):
24   *     is used for specify all values (eg. 'every minute' in minute field)
25   *
26   * Comma (,):
27   *     is used for to separate items of a list (eg. using "MON,WED,FRI"
28   *     in  the 6th field (day of week) means Mondays, Wednesdays, Fridays)
29   *
30   * Hyphen (-):
31   *     is used for define ranges (eg. 10-30 in 1st field means every second
32   *     between 10 and 30 inclusive)
33   *
34   * Slash (/):
35   *     is used for define step values, a/x = a, a+x, a+2x, a+3x, ...
36   *     (eg. 2/4 in hours means list of 2,6,10,14,18,22)
37   *     '*' mean minimal allowed value (eg. 0 for hours, 1 for months etc)
38   *
39   * Question mark (?):
40   *     is a synonym of '*' for day-of-week and day-of-month fields used
41   *     to explicitly indicate that days are set with other field
42   *
43   *
44   * NOTES:
45   *     - If both the 'Day-of-month' and 'Day-of-week' fields are
46   *       restricted (aren't '*'), next correct time will be when both(!)
47   *       fields match the conditions.
48   *       For example: `* * * 13 * FRI` expression will be satisfied only
49   *       on friday 13th.
50   *     - Ranges can be overflowing, it means range 'NOV-FEB' (NOV > FEB)
51   *       will expand in NOV,DEC,JAN,FEB
52   *
53   *
54   *
55   * Copyright:
56   *     Copyright (c) 2018, Maxim Tyapkin.
57   * Authors:
58   *     Maxim Tyapkin
59   * License:
60   *     This software is licensed under the terms of the BSD 3-clause license.
61   *     The full terms of the license can be found in the LICENSE.md file.
62   */
63 
64 module cronexp.cronexp;
65 
66 
67 private
68 {
69     import std.algorithm : filter, each, map;
70     import std.array : array, split;
71     import std.conv : to;
72     import std.datetime;
73     import std.range : iota;
74     import std.regex : ctRegex, matchFirst, Captures;
75     import std.traits : isSomeString, EnumMembers;
76     import std.typecons : tuple, Tuple, Nullable;
77 
78     import cronexp.utils;
79 }
80 
81 
82 
83 alias Interval = Tuple!(ubyte, "from", ubyte, "to");
84 
85 enum Range : Interval
86 {
87     sec = Interval(0, 59),
88     min = Interval(0, 59),
89     hrs = Interval(0, 23),
90     dom = Interval(1, 31),
91     mon = Interval(1, 12),
92     dow = Interval(1, 7)
93 }
94 
95 
96 mixin template RegexNamesEnum(string Name, Fields...)
97 {
98     private static string generate()
99     {
100         auto result = "enum " ~ Name ~ " : string {";
101 
102         foreach (field; Fields)
103             result ~= field ~ " = r\"" ~ Name ~ field ~ "\",";
104 
105         return result ~ "}";
106     }
107 
108     mixin(generate());
109 }
110 
111 
112 mixin RegexNamesEnum!("Sec", "list", "range", "seq", "any");
113 mixin RegexNamesEnum!("Min", "list", "range", "seq", "any");
114 mixin RegexNamesEnum!("Hrs", "list", "range", "seq", "any");
115 mixin RegexNamesEnum!("Dom", "list", "range", "seq", "undef", "any");
116 mixin RegexNamesEnum!("Mon", "list", "range", "seq", "listN", "rangeN", "seqN", "any");
117 mixin RegexNamesEnum!("Dow", "list", "range", "seq", "listN", "rangeN", "seqN", "undef", "any");
118 
119 
120 enum moyNames = r"JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC";
121 enum dowNames = r"MON|TUE|WED|THU|FRI|SAT|SUN";
122 
123 enum secReg = r"(?P<"~Sec.list~r">(([0-9]|[0-5][0-9]),)*([0-9]|[0-5][0-9]))" ~ r"|" ~ 
124               r"(?P<"~Sec.range~r">([0-9]|[0-5][0-9])-([0-9]|[0-5][0-9]))" ~ r"|" ~ 
125               r"(?P<"~Sec.seq~r">([\*]|[0-9]|[0-5][0-9])\/([0-9]|[0-5][0-9]))" ~ r"|" ~ 
126               r"(?P<"~Sec.any~r">[\*])";
127 
128 enum minReg = r"(?P<"~Min.list~r">(([0-9]|[0-5][0-9]),)*([0-9]|[0-5][0-9]))" ~ r"|" ~ 
129               r"(?P<"~Min.range~r">([0-9]|[0-5][0-9])-([0-9]|[0-5][0-9]))" ~ r"|" ~ 
130               r"(?P<"~Min.seq~r">([\*]|[0-9]|[0-5][0-9])\/([0-9]|[0-5][0-9]))" ~ r"|" ~ 
131               r"(?P<"~Min.any~r">[\*])";
132 
133 enum hrsReg = r"(?P<"~Hrs.list~r">(([0-9]|[0-1][0-9]|[2][0-3]),)*([0-9]|[0-1][0-9]|[2][0-3]))" ~ r"|" ~
134               r"(?P<"~Hrs.range~r">([0-9]|[0-1][0-9]|[2][0-3])-([0-9]|[0-1][0-9]|[2][0-3]))" ~ r"|" ~
135               r"(?P<"~Hrs.seq~r">([\*]|[0-9]|[0-1][0-9]|[2][0-3])\/([0-9]|[0-1][0-9]|[2][0-3]))" ~ r"|" ~
136               r"(?P<"~Hrs.any~r">[\*])";
137 
138 enum domReg = r"(?P<"~Dom.list~r">(([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1]),)*([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1]))" ~ r"|" ~
139               r"(?P<"~Dom.range~r">([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1])-([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1]))" ~ r"|" ~
140               r"(?P<"~Dom.seq~r">([\*]|[1-9]|[0][1-9]|[1-2][0-9]|[3][0-1])\/([1-9]|[0][1-9]|[1-2][0-9]|[3][0-1]))" ~ r"|" ~
141               r"(?P<"~Dom.undef~r">[\?])" ~ r"|" ~
142               r"(?P<"~Dom.any~r">[\*])";
143 
144 enum monReg = r"(?P<"~Mon.list~r">(([1-9]|0[1-9]|1[0-2]),)*([1-9]|0[1-9]|1[0-2]))" ~ r"|" ~
145               r"(?P<"~Mon.range~r">([1-9]|0[1-9]|1[0-2])-([1-9]|0[1-9]|1[0-2]))" ~ r"|" ~
146               r"(?P<"~Mon.seq~r">([\*]|[1-9]|0[1-9]|1[0-2])\/([1-9]|0[1-9]|1[0-2]))" ~ r"|" ~
147               r"(?P<"~Mon.listN~r">((" ~ moyNames ~ r"),)*(" ~ moyNames ~ r"))" ~ r"|" ~
148               r"(?P<"~Mon.rangeN~r">(" ~ moyNames ~ r")-(" ~ moyNames ~ r"))" ~ r"|" ~
149               r"(?P<"~Mon.seqN~r">(" ~ moyNames ~ r")\/([1-9]|0[1-9]|1[0-2]))" ~ r"|" ~
150               r"(?P<"~Mon.any~r">[\*])";
151 
152 enum dowReg = r"(?P<"~Dow.list~r">([1-7],)*([1-7]))" ~ r"|" ~
153               r"(?P<"~Dow.range~r">[1-7]-[1-7])" ~ r"|" ~
154               r"(?P<"~Dow.seq~r">([[\*]|[1-7]])\/([1-7]))" ~ r"|" ~
155               r"(?P<"~Dow.listN~r">(("~ dowNames ~ r"),)*(" ~ dowNames ~ r"))" ~ r"|" ~
156               r"(?P<"~Dow.rangeN~r">(" ~ dowNames ~ r")-(" ~ dowNames ~ r"))" ~ r"|" ~
157               r"(?P<"~Dow.seqN~r">(" ~ dowNames ~ r")\/([1-7]))" ~ r"|" ~
158               r"(?P<"~Dow.undef~r">[\?])" ~ r"|" ~
159               r"(?P<"~Dow.any~r">[\*])";
160 
161 enum cronReg = r"^(" ~
162                     secReg ~ r")[\s](" ~ 
163                     minReg ~ r")[\s](" ~
164                     hrsReg ~ r")[\s](" ~
165                     domReg ~ r")[\s](" ~
166                     monReg ~ r")[\s](" ~
167                     dowReg ~
168                 r")$";
169 
170 
171 enum secKeys = [EnumMembers!Sec];
172 enum minKeys = [EnumMembers!Min];
173 enum hrsKeys = [EnumMembers!Hrs];
174 enum domKeys = [EnumMembers!Dom];
175 enum monKeys = [EnumMembers!Mon];
176 enum dowKeys = [EnumMembers!Dow];
177 
178 
179 unittest
180 {
181     auto expr = "10,11,12 0-12 */12 ? JAN/3 *";
182 
183     auto regexp = ctRegex!(cronReg);
184     auto match = matchFirst(expr, regexp);
185 
186     assert(match[cast(string)Sec.list].length);
187     assert(match[cast(string)Min.range].length);
188     assert(match[cast(string)Hrs.seq].length);
189     assert(match[cast(string)Dom.undef].length);
190     assert(match[cast(string)Mon.seqN].length);
191     assert(match[cast(string)Dow.any].length);
192 }
193 
194 
195 
196 /**
197   * Struct containing parsed cron expression
198   */
199 struct CronExpr
200 {
201     ulong  seconds;
202     ulong  minutes;
203     uint   hours;
204     uint   doms;
205     ushort months;
206     ubyte  dows;
207 
208 
209     static auto opCall(R)(R expr)
210         if (isSomeString!R)
211     {
212         CronExpr cron;
213 
214         auto regexp = ctRegex!(cronReg);
215         auto match = matchFirst(expr, regexp);
216 
217         if (match.empty)
218             throw new CronException("Invalid cron expression");
219 
220         cron.parseSeconds!R(match);
221         cron.parseMinutes!R(match);
222         cron.parseHours!R(match);
223         cron.parseDaysOfMonth!R(match);
224         cron.parseMonths!R(match);
225         cron.parseDaysOfWeek(match);
226 
227         return cron;
228     }
229 
230     /**
231       * Get next date of execution after current
232       */
233     Nullable!DateTime getNext(DateTime current)
234     {
235         DateTime next = current;
236         next += 1.seconds;
237 
238         uint ind = 0;
239         while (true)
240         {
241             // Break loop after 4 years
242             if (ind > 4 * 366)
243                 return Nullable!DateTime.init;
244 
245             // Find next valid month
246             if (!bitTest(months, next.month))
247             {
248                 ind += next.daysInMonth;
249                 next.add!"months"(1);
250                 next.day = 1;
251                 next.hour = 0;
252                 next.minute = 0;
253                 next.second = 0;
254                 continue;
255             }
256 
257             // Find next valid day of month
258             if (!bitTest(doms, next.day))
259             {
260                 ind += 1;
261                 next += 1.days;
262                 next.hour = 0;
263                 next.minute = 0;
264                 next.second = 0;
265                 continue;
266             }
267 
268             // Find next valid day of week
269             if (!bitTest(dows, next.dow))
270             {
271                 ind += 1;
272                 next += 1.days;
273                 next.hour = 0;
274                 next.minute = 0;
275                 next.second = 0;
276                 continue;
277             }
278 
279             // Find next valid hour
280             if (!bitTest(hours, next.hour))
281             {
282                 next += 1.hours;
283                 next.minute = 0;
284                 next.second = 0;
285                 continue;
286             }
287 
288             // Find next valid minute
289             if (!bitTest(minutes, next.minute))
290             {
291                 next += 1.minutes;
292                 next.second = 0;
293                 continue;
294             }
295 
296             // Find next valid second
297             if (!bitTest(seconds, next.second))
298             {
299                 next += 1.seconds;
300                 continue;
301             }
302             
303            break; 
304         }
305 
306         return Nullable!DateTime(next);
307     }
308 
309 
310 private:
311 
312 
313     /**
314       * Method for parsing `seconds` part of expression
315       */
316     void parseSeconds(R)(ref Captures!R match)
317     {
318         auto keys = secKeys
319                     .filter!(a => match[cast(string)a].length)
320                     .array;
321 
322         if (!keys.length)
323             throw new CronException("Undefined expression while parsing seconds");
324 
325         auto expr = match[cast(string)(keys[0])];
326         final switch (keys[0]) with (Sec)
327         {
328             case list:
329                 parseList(seconds, expr);
330                 return;
331             case range:
332                 parseRange(seconds, expr, Range.sec.from, Range.sec.to);
333                 return;
334             case seq:
335                 parseSequence(seconds, expr, Range.sec.from, Range.sec.to);
336                 return;
337             case any:
338                 parseAny(seconds, Range.sec.from, Range.sec.to);
339                 return;
340         }
341     }
342 
343 
344     /**
345       * Method for parsing `minutes` part of expression
346       */
347     void parseMinutes(R)(ref Captures!R match)
348     {
349         auto keys = minKeys
350                     .filter!(a => match[cast(string)a].length)
351                     .array;
352 
353         if (!keys.length)
354             throw new CronException("Undefined expression while parsing minutes");
355 
356         auto expr = match[cast(string)(keys[0])];
357         final switch (keys[0]) with (Min)
358         {
359             case list:
360                 parseList(minutes, expr);
361                 return;
362             case range:
363                 parseRange(minutes, expr, Range.min.from, Range.min.to);
364                 return;
365             case seq:
366                 parseSequence(minutes, expr, Range.min.from, Range.min.to);
367                 return;
368             case any:
369                 parseAny(minutes, Range.min.from, Range.min.to);
370                 return;
371         }
372     }
373 
374 
375     /**
376       * Method for parsing `hours` part of expression
377       */
378     void parseHours(R)(ref Captures!R match)
379     {
380         auto keys = hrsKeys
381                     .filter!(a => match[cast(string)a].length)
382                     .array;
383 
384         if (!keys.length)
385             throw new CronException("Undefined expression while parsing hours");
386 
387         auto expr = match[cast(string)(keys[0])];
388         final switch (keys[0]) with (Hrs)
389         {
390             case list:
391                 parseList(hours, expr);
392                 return;
393             case range:
394                 parseRange(hours, expr, Range.hrs.from, Range.hrs.to);
395                 return;
396             case seq:
397                 parseSequence(hours, expr, Range.hrs.from, Range.hrs.to);
398                 return;
399             case any:
400                 parseAny(hours, Range.hrs.from, Range.hrs.to);
401                 return;
402         }
403     }
404 
405 
406     /**
407       * Method for parsing `days of month` part of expression
408       */
409     void parseDaysOfMonth(R)(ref Captures!R match)
410     {
411         auto keys = domKeys
412                     .filter!(a => match[cast(string)a].length)
413                     .array;
414 
415         if (!keys.length)
416             throw new CronException("Undefined expression while parsing day of months");
417 
418         auto expr = match[cast(string)(keys[0])];
419         final switch (keys[0]) with (Dom)
420         {
421             case list:
422                 parseList(doms, expr);
423                 return;
424             case range:
425                 parseRange(doms, expr, Range.dom.from, Range.dom.to);
426                 return;
427             case seq:
428                 parseSequence(doms, expr, Range.dom.from, Range.dom.to);
429                 return;
430             case undef:
431             case any:
432                 parseAny(doms, Range.dom.from, Range.dom.to);
433                 return;
434         }
435     }
436 
437 
438     /**
439       * Method for parsing `months` part of expression
440       */
441     void parseMonths(R)(ref Captures!R match)
442     {
443         auto keys = monKeys
444                     .filter!(a => match[cast(string)a].length)
445                     .array;
446 
447         if (!keys.length)
448             throw new CronException("Undefined expression while parsing months");
449 
450         auto expr = match[cast(string)(keys[0])];
451         final switch (keys[0]) with (Mon)
452         {
453             case list:
454                 parseList(months, expr);
455                 return;
456             case listN:
457                 parseList(months, expr.replaceNames(moyNames));
458                 return;
459             case range:
460                 parseRange(months, expr, Range.mon.from, Range.mon.to);
461                 return;
462             case rangeN:
463                 parseRange(months, expr.replaceNames(moyNames), Range.mon.from, Range.mon.to);
464                 return;
465             case seq:
466                 parseSequence(months, expr, Range.mon.from, Range.mon.to);
467                 return;
468             case seqN:
469                 parseSequence(months, expr.replaceNames(moyNames), Range.mon.from, Range.mon.to);
470                 return;
471             case any:
472                 parseAny(months, Range.mon.from, Range.mon.to);
473                 return;
474         }
475     }
476 
477 
478     /**
479       * Method for parsing `days of week` part of expression
480       */
481     void parseDaysOfWeek(R)(ref Captures!R match)
482     {
483         auto keys = dowKeys
484                     .filter!(a => match[cast(string)a].length)
485                     .array;
486 
487         if (!keys.length)
488             throw new CronException("Undefined expression while parsing days of week");
489 
490         auto expr = match[cast(string)(keys[0])];
491         final switch (keys[0]) with (Dow)
492         {
493             case list:
494                 parseList(dows, expr);
495                 return;
496             case listN:
497                 parseList(dows, expr.replaceNames(dowNames));
498                 return;
499             case range:
500                 parseRange(dows, expr, Range.dow.from, Range.dow.to);
501                 return;
502             case rangeN:
503                 parseRange(dows, expr.replaceNames(dowNames), Range.dow.from, Range.dow.to);
504                 return;
505             case seq:
506                 parseSequence(dows, expr, Range.dow.from, Range.dow.to);
507                 return;
508             case seqN:
509                 parseSequence(dows, expr.replaceNames(dowNames), Range.dow.from, Range.dow.to);
510                 return;
511             case undef:
512             case any:
513                 parseAny(dows, Range.dow.from, Range.dow.to);
514                 return;
515         }
516     }
517 }
518 
519 
520 unittest
521 {
522     auto c1 = CronExpr("30 0-1 12 1/2 NOV-FEB 2/2");
523     auto d1 = DateTime(2000, 6, 1, 10, 30, 0);
524     assert(c1.getNext(d1).get == DateTime(2000, 11, 7, 12, 00, 30));
525 
526     auto c2 = CronExpr("0 0 0 29 FEB THU");
527     auto d2 = DateTime(2000, 1, 1, 0, 0, 0);
528     assert(c2.getNext(d2).isNull);
529 }
530 
531 
532 /**
533   * Cron exception
534   */
535 class CronException : Exception
536 {
537     this(string msg, string file = __FILE__, size_t line = __LINE__)
538     {
539         super(msg, file, line);
540     }
541 }
542 
543 
544 /**
545   * Replace names of dows and doms in expression
546   */
547 auto replaceNames(R)(R expr, string names)
548     if (isSomeString!R)
549 {
550     import std.array : replace; 
551 
552     R result = expr;
553 
554     ubyte i = 0;
555     names
556         .split("|")
557         .map!(a => tuple(a, to!string(++i)))
558         .each!(a => result = replace(result, a[0], a[1]));
559 
560     return result;
561 }
562 
563 
564 unittest
565 {
566     auto test = "JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC MON,TUE,WED,THU,FRI,SAT,SUN";
567     auto expected = "1,2,3,4,5,6,7,8,9,10,11,12 1,2,3,4,5,6,7";
568     auto result = test
569                     .replaceNames(moyNames)
570                     .replaceNames(dowNames);
571     assert(result == expected);
572 }