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 }