1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Cron; |
4
|
|
|
|
5
|
|
|
/** |
6
|
|
|
* Cron expression parser and validator |
7
|
|
|
* |
8
|
|
|
* @author René Pollesch |
9
|
|
|
*/ |
10
|
|
|
class CronExpression |
11
|
|
|
{ |
12
|
|
|
/** |
13
|
|
|
* Weekday look-up table |
14
|
|
|
* |
15
|
|
|
* @var array |
16
|
|
|
*/ |
17
|
|
|
protected static $weekdays = [ |
18
|
|
|
'sun' => 0, |
19
|
|
|
'mon' => 1, |
20
|
|
|
'tue' => 2, |
21
|
|
|
'wed' => 3, |
22
|
|
|
'thu' => 4, |
23
|
|
|
'fri' => 5, |
24
|
|
|
'sat' => 6 |
25
|
|
|
]; |
26
|
|
|
|
27
|
|
|
/** |
28
|
|
|
* Month name look-up table |
29
|
|
|
* |
30
|
|
|
* @var array |
31
|
|
|
*/ |
32
|
|
|
protected static $months = [ |
33
|
|
|
'jan' => 1, |
34
|
|
|
'feb' => 2, |
35
|
|
|
'mar' => 3, |
36
|
|
|
'apr' => 4, |
37
|
|
|
'may' => 5, |
38
|
|
|
'jun' => 6, |
39
|
|
|
'jul' => 7, |
40
|
|
|
'aug' => 8, |
41
|
|
|
'sep' => 9, |
42
|
|
|
'oct' => 10, |
43
|
|
|
'nov' => 11, |
44
|
|
|
'dec' => 12 |
45
|
|
|
]; |
46
|
|
|
|
47
|
|
|
/** |
48
|
|
|
* Value boundaries |
49
|
|
|
* |
50
|
|
|
* @var array |
51
|
|
|
*/ |
52
|
|
|
protected static $boundaries = [ |
53
|
|
|
0 => [ |
54
|
|
|
'min' => 0, |
55
|
|
|
'max' => 59 |
56
|
|
|
], |
57
|
|
|
1 => [ |
58
|
|
|
'min' => 0, |
59
|
|
|
'max' => 23 |
60
|
|
|
], |
61
|
|
|
2 => [ |
62
|
|
|
'min' => 1, |
63
|
|
|
'max' => 31 |
64
|
|
|
], |
65
|
|
|
3 => [ |
66
|
|
|
'min' => 1, |
67
|
|
|
'max' => 12 |
68
|
|
|
], |
69
|
|
|
4 => [ |
70
|
|
|
'min' => 0, |
71
|
|
|
'max' => 7 |
72
|
|
|
] |
73
|
|
|
]; |
74
|
|
|
|
75
|
|
|
/** |
76
|
|
|
* Cron expression |
77
|
|
|
* |
78
|
|
|
* @var string |
79
|
|
|
*/ |
80
|
|
|
protected $expression; |
81
|
|
|
|
82
|
|
|
/** |
83
|
|
|
* Time zone |
84
|
|
|
* |
85
|
|
|
* @var \DateTimeZone |
86
|
|
|
*/ |
87
|
|
|
protected $timeZone; |
88
|
|
|
|
89
|
|
|
/** |
90
|
|
|
* Matching register |
91
|
|
|
* |
92
|
|
|
* @var array|null |
93
|
|
|
*/ |
94
|
|
|
protected $register; |
95
|
|
|
|
96
|
|
|
/** |
97
|
|
|
* Class constructor sets cron expression property |
98
|
|
|
* |
99
|
|
|
* @param string $expression cron expression |
100
|
|
|
* @param \DateTimeZone|null $timeZone |
101
|
|
|
*/ |
102
|
90 |
|
public function __construct(string $expression = '* * * * *', \DateTimeZone $timeZone = null) |
103
|
|
|
{ |
104
|
90 |
|
$this->setExpression($expression); |
105
|
90 |
|
$this->setTimeZone($timeZone); |
106
|
90 |
|
} |
107
|
|
|
|
108
|
|
|
/** |
109
|
|
|
* Set expression |
110
|
|
|
* |
111
|
|
|
* @param string $expression |
112
|
|
|
* @return self |
113
|
|
|
*/ |
114
|
90 |
|
public function setExpression(string $expression): self |
115
|
|
|
{ |
116
|
90 |
|
$this->expression = trim($expression); |
117
|
90 |
|
$this->register = null; |
118
|
|
|
|
119
|
90 |
|
return $this; |
120
|
|
|
} |
121
|
|
|
|
122
|
|
|
/** |
123
|
|
|
* Set time zone |
124
|
|
|
* |
125
|
|
|
* @param \DateTimeZone|null $timeZone |
126
|
|
|
* @return self |
127
|
|
|
*/ |
128
|
90 |
|
public function setTimeZone(\DateTimeZone $timeZone = null): self |
129
|
|
|
{ |
130
|
90 |
|
$this->timeZone = $timeZone; |
131
|
90 |
|
return $this; |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
/** |
135
|
|
|
* Calculate next matching timestamp |
136
|
|
|
* |
137
|
|
|
* @param mixed $start either a \DateTime object, a timestamp or null for current date/time |
138
|
|
|
* @return int|bool next matching timestamp, or false on error |
139
|
|
|
*/ |
140
|
8 |
|
public function getNext($start = null) |
141
|
|
|
{ |
142
|
8 |
|
$result = false; |
143
|
|
|
|
144
|
8 |
|
if ($this->isValid()) { |
145
|
8 |
|
if ($start instanceof \DateTime) { |
146
|
1 |
|
$timestamp = $start->getTimestamp(); |
147
|
7 |
|
} elseif ((int)$start > 0) { |
148
|
6 |
|
$timestamp = $start; |
149
|
|
|
} else { |
150
|
1 |
|
$timestamp = time(); |
151
|
|
|
} |
152
|
|
|
|
153
|
8 |
|
$now = new \DateTime('now', $this->timeZone); |
154
|
8 |
|
$now->setTimestamp(ceil($timestamp / 60) * 60); |
155
|
|
|
|
156
|
8 |
|
if ($this->isMatching($now)) { |
157
|
2 |
|
$now->modify('+1 minute'); |
158
|
|
|
} |
159
|
|
|
|
160
|
8 |
|
$pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d'); |
161
|
|
|
|
162
|
|
|
do { |
163
|
8 |
|
$current = $this->adjust($now, $pointer); |
164
|
8 |
|
} while ($this->forward($now, $current)); |
165
|
|
|
|
166
|
8 |
|
$result = $now->getTimestamp(); |
167
|
|
|
} |
168
|
|
|
|
169
|
8 |
|
return $result; |
170
|
|
|
} |
171
|
|
|
|
172
|
|
|
/** |
173
|
|
|
* @param \DateTime $now |
174
|
|
|
* @param array $pointer |
175
|
|
|
* @return array |
176
|
|
|
*/ |
177
|
8 |
|
private function adjust(\DateTime $now, array &$pointer): array |
178
|
|
|
{ |
179
|
8 |
|
$current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d'); |
180
|
|
|
|
181
|
|
|
switch (true) { |
182
|
8 |
View Code Duplication |
case ($pointer[1] !== $current[1]): |
|
|
|
|
183
|
5 |
|
$pointer[1] = $current[1]; |
184
|
5 |
|
$now->setTime($current[1], 0); |
185
|
5 |
|
break; |
186
|
|
|
|
187
|
8 |
View Code Duplication |
case ($pointer[0] !== $current[0]): |
|
|
|
|
188
|
7 |
|
$pointer[0] = $current[0]; |
189
|
7 |
|
$now->setTime($current[1], $current[0]); |
190
|
7 |
|
break; |
191
|
|
|
|
192
|
8 |
View Code Duplication |
case ($pointer[4] !== $current[4]): |
|
|
|
|
193
|
1 |
|
$pointer[4] = $current[4]; |
194
|
1 |
|
$now->setDate($current[4], 1, 1); |
195
|
1 |
|
$now->setTime(0, 0); |
196
|
1 |
|
break; |
197
|
|
|
|
198
|
8 |
View Code Duplication |
case ($pointer[3] !== $current[3]): |
|
|
|
|
199
|
2 |
|
$pointer[3] = $current[3]; |
200
|
2 |
|
$now->setDate($current[4], $current[3], 1); |
201
|
2 |
|
$now->setTime(0, 0); |
202
|
2 |
|
break; |
203
|
|
|
|
204
|
8 |
View Code Duplication |
case ($pointer[2] !== $current[2]): |
|
|
|
|
205
|
2 |
|
$pointer[2] = $current[2]; |
206
|
2 |
|
$now->setTime(0, 0); |
207
|
2 |
|
break; |
208
|
|
|
} |
209
|
|
|
|
210
|
8 |
|
return $current; |
211
|
|
|
} |
212
|
|
|
|
213
|
|
|
/** |
214
|
|
|
* @param \DateTime $now |
215
|
|
|
* @param array $current |
216
|
|
|
* @return bool |
217
|
|
|
*/ |
218
|
8 |
|
private function forward(\DateTime $now, array $current): bool |
219
|
|
|
{ |
220
|
8 |
|
$result = false; |
221
|
|
|
|
222
|
8 |
|
if (isset($this->register[3][$current[3]]) === false) { |
223
|
1 |
|
$now->modify('+1 month'); |
224
|
1 |
|
$result = true; |
225
|
8 |
|
} elseif (false === (isset($this->register[2][$current[2]]) && isset($this->register[4][$current[5]]))) { |
226
|
2 |
|
$now->modify('+1 day'); |
227
|
2 |
|
$result = true; |
228
|
8 |
|
} elseif (isset($this->register[1][$current[1]]) === false) { |
229
|
4 |
|
$now->modify('+1 hour'); |
230
|
4 |
|
$result = true; |
231
|
8 |
|
} elseif (isset($this->register[0][$current[0]]) === false) { |
232
|
6 |
|
$now->modify('+1 minute'); |
233
|
6 |
|
$result = true; |
234
|
|
|
} |
235
|
|
|
|
236
|
8 |
|
return $result; |
237
|
|
|
} |
238
|
|
|
|
239
|
|
|
/** |
240
|
|
|
* Parse and validate cron expression |
241
|
|
|
* |
242
|
|
|
* @return bool true if expression is valid, or false on error |
243
|
|
|
*/ |
244
|
89 |
|
public function isValid(): bool |
245
|
|
|
{ |
246
|
89 |
|
$result = true; |
247
|
|
|
|
248
|
89 |
|
if ($this->register === null) { |
249
|
|
|
try { |
250
|
89 |
|
$this->register = $this->parse(); |
251
|
27 |
|
} catch (\Exception $e) { |
252
|
27 |
|
$result = false; |
253
|
|
|
} |
254
|
|
|
} |
255
|
|
|
|
256
|
89 |
|
return $result; |
257
|
|
|
} |
258
|
|
|
|
259
|
|
|
/** |
260
|
|
|
* Match current or given date/time against cron expression |
261
|
|
|
* |
262
|
|
|
* @param mixed $now \DateTime object, timestamp or null |
263
|
|
|
* @return bool |
264
|
|
|
*/ |
265
|
90 |
|
public function isMatching($now = null): bool |
266
|
|
|
{ |
267
|
90 |
|
if (false === ($now instanceof \DateTime)) { |
268
|
81 |
|
$now = (new \DateTime())->setTimestamp($now === null ? time() : $now); |
269
|
|
|
} |
270
|
|
|
|
271
|
90 |
|
if ($this->timeZone !== null) { |
272
|
88 |
|
$now->setTimezone($this->timeZone); |
273
|
|
|
} |
274
|
|
|
|
275
|
|
|
try { |
276
|
90 |
|
$result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d')); |
277
|
27 |
|
} catch (\Exception $e) { |
278
|
27 |
|
$result = false; |
279
|
|
|
} |
280
|
|
|
|
281
|
90 |
|
return $result; |
282
|
|
|
} |
283
|
|
|
|
284
|
|
|
/** |
285
|
|
|
* @param array $segments |
286
|
|
|
* @return bool |
287
|
|
|
* @throws \Exception |
288
|
|
|
*/ |
289
|
90 |
|
private function match(array $segments): bool |
290
|
|
|
{ |
291
|
90 |
|
$result = true; |
292
|
|
|
|
293
|
90 |
|
foreach ($this->parse() as $i => $item) { |
294
|
63 |
|
if (isset($item[(int)$segments[$i]]) === false) { |
295
|
31 |
|
$result = false; |
296
|
63 |
|
break; |
297
|
|
|
} |
298
|
|
|
} |
299
|
|
|
|
300
|
63 |
|
return $result; |
301
|
|
|
} |
302
|
|
|
|
303
|
|
|
/** |
304
|
|
|
* Parse whole cron expression |
305
|
|
|
* |
306
|
|
|
* @return array |
307
|
|
|
* @throws \Exception |
308
|
|
|
*/ |
309
|
90 |
|
private function parse(): array |
310
|
|
|
{ |
311
|
90 |
|
$register = []; |
312
|
|
|
|
313
|
90 |
|
if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) { |
314
|
86 |
|
foreach ($segments as $index => $segment) { |
315
|
86 |
|
$this->parseSegment($index, $register, $segment); |
316
|
|
|
} |
317
|
|
|
|
318
|
63 |
|
if (isset($register[4][7])) { |
319
|
63 |
|
$register[4][0] = true; |
320
|
|
|
} |
321
|
|
|
} else { |
322
|
4 |
|
throw new \Exception('invalid number of segments'); |
323
|
|
|
} |
324
|
|
|
|
325
|
63 |
|
return $register; |
326
|
|
|
} |
327
|
|
|
|
328
|
|
|
/** |
329
|
|
|
* Parse one segment of a cron expression |
330
|
|
|
* |
331
|
|
|
* @param int $index |
332
|
|
|
* @param string $segment |
333
|
|
|
* @param array $register |
334
|
|
|
* @throws \Exception |
335
|
|
|
*/ |
336
|
86 |
|
private function parseSegment($index, array &$register, $segment) |
337
|
|
|
{ |
338
|
86 |
|
$allowed = [false, false, false, self::$months, self::$weekdays]; |
339
|
|
|
|
340
|
|
|
// month names, weekdays |
341
|
86 |
|
if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) { |
342
|
|
|
// cannot be used together with lists or ranges |
343
|
5 |
|
$register[$index][$allowed[$index][strtolower($segment)]] = true; |
344
|
|
|
} else { |
345
|
|
|
// split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ] |
346
|
86 |
|
foreach (explode(',', $segment) as $element) { |
347
|
86 |
|
$this->parseElement($index, $register, $element); |
348
|
|
|
} |
349
|
|
|
} |
350
|
74 |
|
} |
351
|
|
|
|
352
|
|
|
/** |
353
|
|
|
* @param int $index |
354
|
|
|
* @param array $register |
355
|
|
|
* @param string $element |
356
|
|
|
* @throws \Exception |
357
|
|
|
*/ |
358
|
86 |
|
private function parseElement(int $index, array &$register, string $element) |
359
|
|
|
{ |
360
|
86 |
|
$stepping = 1; |
361
|
|
|
|
362
|
86 |
|
if (false !== strpos($element, '/')) { |
363
|
42 |
|
$this->parseStepping($index, $element, $stepping); |
364
|
|
|
} |
365
|
|
|
|
366
|
83 |
|
if (is_numeric($element)) { |
367
|
32 |
|
$this->validateValue($index, $element); |
368
|
|
|
|
369
|
26 |
|
if ($stepping !== 1) { |
370
|
1 |
|
throw new \Exception('invalid combination of value and stepping notation'); |
371
|
|
|
} |
372
|
|
|
|
373
|
25 |
|
$register[$index][intval($element)] = true; |
374
|
|
|
} else { |
375
|
79 |
|
$this->parseRange($index, $register, $element, $stepping); |
376
|
|
|
} |
377
|
75 |
|
} |
378
|
|
|
|
379
|
|
|
/** |
380
|
|
|
* Parse range of values, e.g. "5-10" |
381
|
|
|
* |
382
|
|
|
* @param int $index |
383
|
|
|
* @param array $register |
384
|
|
|
* @param string $range |
385
|
|
|
* @param int $stepping |
386
|
|
|
* @throws \Exception |
387
|
|
|
*/ |
388
|
79 |
|
private function parseRange(int $index, array &$register, string $range, int $stepping) |
389
|
|
|
{ |
390
|
79 |
|
if ($range === '*') { |
391
|
72 |
|
$range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']]; |
392
|
51 |
|
} elseif (strpos($range, '-') !== false) { |
393
|
44 |
|
$range = $this->validateRange($index, explode('-', $range)); |
394
|
|
|
} else { |
395
|
8 |
|
throw new \Exception('failed to parse list segment'); |
396
|
|
|
} |
397
|
|
|
|
398
|
74 |
|
$this->fillRegister($index, $register, $range, $stepping); |
399
|
74 |
|
} |
400
|
|
|
|
401
|
|
|
/** |
402
|
|
|
* Parse stepping notation, e.g. "5-10/2" => 2 |
403
|
|
|
* |
404
|
|
|
* @param int $index |
405
|
|
|
* @param string $element |
406
|
|
|
* @param int $stepping |
407
|
|
|
* @throws \Exception |
408
|
|
|
*/ |
409
|
42 |
|
private function parseStepping(int $index, string &$element, int &$stepping) |
410
|
|
|
{ |
411
|
42 |
|
$segments = explode('/', $element); |
412
|
|
|
|
413
|
42 |
|
$this->validateStepping($index, $segments); |
414
|
|
|
|
415
|
38 |
|
$element = (string)$segments[0]; |
416
|
38 |
|
$stepping = (int)$segments[1]; |
417
|
38 |
|
} |
418
|
|
|
|
419
|
|
|
/** |
420
|
|
|
* Validate whether a given range of values exceeds allowed value boundaries |
421
|
|
|
* |
422
|
|
|
* @param int $index |
423
|
|
|
* @param array $range |
424
|
|
|
* @return array |
425
|
|
|
* @throws \Exception |
426
|
|
|
*/ |
427
|
44 |
|
private function validateRange(int $index, array $range): array |
428
|
|
|
{ |
429
|
44 |
|
if (sizeof($range) !== 2) { |
430
|
1 |
|
throw new \Exception('invalid range notation'); |
431
|
|
|
} |
432
|
|
|
|
433
|
44 |
|
foreach ($range as $value) { |
434
|
44 |
|
$this->validateValue($index, $value); |
435
|
|
|
} |
436
|
|
|
|
437
|
41 |
|
return $range; |
438
|
|
|
} |
439
|
|
|
/** |
440
|
|
|
* @param int $index |
441
|
|
|
* @param string $value |
442
|
|
|
* @throws \Exception |
443
|
|
|
*/ |
444
|
65 |
|
private function validateValue(int $index, string $value) |
445
|
|
|
{ |
446
|
65 |
|
if (is_numeric($value)) { |
447
|
65 |
|
if (intval($value) < self::$boundaries[$index]['min'] || |
448
|
65 |
|
intval($value) > self::$boundaries[$index]['max']) { |
449
|
65 |
|
throw new \Exception('value boundary exceeded'); |
450
|
|
|
} |
451
|
|
|
} else { |
452
|
1 |
|
throw new \Exception('non-integer value'); |
453
|
|
|
} |
454
|
59 |
|
} |
455
|
|
|
|
456
|
|
|
/** |
457
|
|
|
* @param int $index |
458
|
|
|
* @param array $segments |
459
|
|
|
* @throws \Exception |
460
|
|
|
*/ |
461
|
42 |
|
private function validateStepping(int $index, array $segments) |
462
|
|
|
{ |
463
|
42 |
|
if (sizeof($segments) !== 2) { |
464
|
1 |
|
throw new \Exception('invalid stepping notation'); |
465
|
|
|
} |
466
|
|
|
|
467
|
41 |
|
if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) { |
468
|
3 |
|
throw new \Exception('stepping out of allowed range'); |
469
|
|
|
} |
470
|
38 |
|
} |
471
|
|
|
|
472
|
|
|
/** |
473
|
|
|
* @param int $index |
474
|
|
|
* @param array $register |
475
|
|
|
* @param array $range |
476
|
|
|
* @param int $stepping |
477
|
|
|
*/ |
478
|
74 |
|
private function fillRegister(int $index, array &$register, array $range, int $stepping) |
479
|
|
|
{ |
480
|
74 |
|
for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) { |
481
|
74 |
|
if (($i - $range[0]) % $stepping === 0) { |
482
|
74 |
|
if ($range[0] < $range[1]) { |
483
|
73 |
|
$this->fillRegisterBetweenBoundaries($index, $register, $range, $i); |
484
|
|
|
} else { |
485
|
29 |
|
$this->fillRegisterAcrossBoundaries($index, $register, $range, $i); |
486
|
|
|
} |
487
|
|
|
} |
488
|
|
|
} |
489
|
74 |
|
} |
490
|
|
|
|
491
|
|
|
/** |
492
|
|
|
* @param int $index |
493
|
|
|
* @param array $register |
494
|
|
|
* @param array $range |
495
|
|
|
* @param int $value |
496
|
|
|
*/ |
497
|
29 |
|
private function fillRegisterAcrossBoundaries(int $index, array &$register, array $range, int $value) |
498
|
|
|
{ |
499
|
29 |
View Code Duplication |
if ($value >= $range[0] || $value <= $range[1]) { |
|
|
|
|
500
|
29 |
|
$register[$index][$value] = true; |
501
|
|
|
} |
502
|
29 |
|
} |
503
|
|
|
|
504
|
|
|
/** |
505
|
|
|
* @param int $index |
506
|
|
|
* @param array $register |
507
|
|
|
* @param array $range |
508
|
|
|
* @param int $value |
509
|
|
|
*/ |
510
|
73 |
|
private function fillRegisterBetweenBoundaries(int $index, array &$register, array $range, int $value) |
511
|
|
|
{ |
512
|
73 |
View Code Duplication |
if ($value >= $range[0] && $value <= $range[1]) { |
|
|
|
|
513
|
73 |
|
$register[$index][$value] = true; |
514
|
|
|
} |
515
|
73 |
|
} |
516
|
|
|
} |
517
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.