Completed
Push — master ( 5093d0...e3613e )
by René
02:17
created

CronExpression::getNext()   A

Complexity

Conditions 6
Paths 7

Size

Total Lines 30
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 18
nc 7
nop 1
dl 0
loc 30
ccs 17
cts 17
cp 1
crap 6
rs 9.0444
c 0
b 0
f 0
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 93
    public function __construct(string $expression = '* * * * *', \DateTimeZone $timeZone = null)
103
    {
104 93
        $this->setExpression($expression);
105 93
        $this->setTimeZone($timeZone);
106 93
    }
107
108
    /**
109
     * Set expression
110
     *
111
     * @param string $expression
112
     * @return self
113
     */
114 93
    public function setExpression(string $expression): self
115
    {
116 93
        $this->expression = trim($expression);
117 93
        $this->register = null;
118
119 93
        return $this;
120
    }
121
122
    /**
123
     * Set time zone
124
     *
125
     * @param \DateTimeZone|null $timeZone
126
     * @return self
127
     */
128 93
    public function setTimeZone(\DateTimeZone $timeZone = null): self
129
    {
130 93
        $this->timeZone = $timeZone;
131 93
        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
     * @throws \Exception
140
     */
141 11
    public function getNext($start = null)
142
    {
143 11
        $result = false;
144
145 11
        if ($this->isValid()) {
146 11
            if ($start instanceof \DateTime) {
147 1
                $timestamp = $start->getTimestamp();
148 10
            } elseif ((int)$start > 0) {
149 9
                $timestamp = $start;
150
            } else {
151 1
                $timestamp = time();
152
            }
153
154 11
            $now = new \DateTime('now', $this->timeZone);
155 11
            $now->setTimestamp(ceil($timestamp / 60) * 60);
0 ignored issues
show
Bug introduced by
ceil($timestamp / 60) * 60 of type double is incompatible with the type integer expected by parameter $unixtimestamp of DateTime::setTimestamp(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

155
            $now->setTimestamp(/** @scrutinizer ignore-type */ ceil($timestamp / 60) * 60);
Loading history...
156
157 11
            if ($this->isMatching($now)) {
158 4
                $now->modify('+1 minute');
159
            }
160
161 11
            $pointer = sscanf($now->format('i G j n Y'), '%d %d %d %d %d');
162
163
            do {
164 11
                $current = $this->adjust($now, $pointer);
165 11
            } while ($this->forward($now, $current));
166
167 11
            $result = $now->getTimestamp();
168
        }
169
170 11
        return $result;
171
    }
172
173
    /**
174
     * @param \DateTime $now
175
     * @param array $pointer
176
     * @return array
177
     */
178 11
    private function adjust(\DateTime $now, array &$pointer): array
179
    {
180 11
        $current = sscanf($now->format('i G j n Y w'), '%d %d %d %d %d %d');
181
182
        switch (true) {
183 11
            case ($pointer[1] !== $current[1]):
184 6
                $pointer[1] = $current[1];
185 6
                $now->setTime($current[1], 0);
186 6
                break;
187
188 11
            case ($pointer[0] !== $current[0]):
189 9
                $pointer[0] = $current[0];
190 9
                $now->setTime($current[1], $current[0]);
191 9
                break;
192
193 11
            case ($pointer[4] !== $current[4]):
194 1
                $pointer[4] = $current[4];
195 1
                $now->setDate($current[4], 1, 1);
196 1
                $now->setTime(0, 0);
197 1
                break;
198
199 11
            case ($pointer[3] !== $current[3]):
200 2
                $pointer[3] = $current[3];
201 2
                $now->setDate($current[4], $current[3], 1);
202 2
                $now->setTime(0, 0);
203 2
                break;
204
205 11
            case ($pointer[2] !== $current[2]):
206 2
                $pointer[2] = $current[2];
207 2
                $now->setTime(0, 0);
208 2
                break;
209
        }
210
211 11
        return $current;
212
    }
213
214
    /**
215
     * @param \DateTime $now
216
     * @param array $current
217
     * @return bool
218
     */
219 11
    private function forward(\DateTime $now, array $current): bool
0 ignored issues
show
Unused Code introduced by
The method forward() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
220
    {
221 11
        $result = false;
222
223 11
        if (isset($this->register[3][$current[3]]) === false) {
224 1
            $now->modify('+1 month');
225 1
            $result = true;
226 11
        } elseif (false === (isset($this->register[2][$current[2]]) && isset($this->register[4][$current[5]]))) {
227 2
            $now->modify('+1 day');
228 2
            $result = true;
229 11
        } elseif (isset($this->register[1][$current[1]]) === false) {
230 4
            $now->modify('+1 hour');
231 4
            $result = true;
232 11
        } elseif (isset($this->register[0][$current[0]]) === false) {
233 8
            $now->modify('+1 minute');
234 8
            $result = true;
235
        }
236
237 11
        return $result;
238
    }
239
240
    /**
241
     * Parse and validate cron expression
242
     *
243
     * @return bool true if expression is valid, or false on error
244
     */
245 92
    public function isValid(): bool
246
    {
247 92
        $result = true;
248
249 92
        if ($this->register === null) {
250
            try {
251 92
                $this->register = $this->parse();
252 27
            } catch (\Exception $e) {
253 27
                $result = false;
254
            }
255
        }
256
257 92
        return $result;
258
    }
259
260
    /**
261
     * Match current or given date/time against cron expression
262
     *
263
     * @param mixed $now \DateTime object, timestamp or null
264
     * @return bool
265
     * @throws \Exception
266
     */
267 93
    public function isMatching($now = null): bool
268
    {
269 93
        if (false === ($now instanceof \DateTime)) {
270 81
            $now = (new \DateTime())->setTimestamp($now === null ? time() : $now);
271
        }
272
273 93
        if ($this->timeZone !== null) {
274 91
            $now->setTimezone($this->timeZone);
275
        }
276
277
        try {
278 93
            $result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d'));
279 27
        } catch (\Exception $e) {
280 27
            $result = false;
281
        }
282
283 93
        return $result;
284
    }
285
286
    /**
287
     * @param array $segments
288
     * @return bool
289
     * @throws \Exception
290
     */
291 93
    private function match(array $segments): bool
292
    {
293 93
        $result = true;
294
295 93
        foreach ($this->parse() as $i => $item) {
296 66
            if (isset($item[(int)$segments[$i]]) === false) {
297 32
                $result = false;
298 66
                break;
299
            }
300
        }
301
302 66
        return $result;
303
    }
304
305
    /**
306
     * Parse whole cron expression
307
     *
308
     * @return array
309
     * @throws \Exception
310
     */
311 93
    private function parse(): array
312
    {
313 93
        $register = [];
314
315 93
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
0 ignored issues
show
Bug introduced by
It seems like $segments = preg_split('...+/', $this->expression) can also be of type false; however, parameter $var of sizeof() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

315
        if (sizeof(/** @scrutinizer ignore-type */ $segments = preg_split('/\s+/', $this->expression)) === 5) {
Loading history...
316 89
            foreach ($segments as $index => $segment) {
317 89
                $this->parseSegment($index, $register, $segment);
318
            }
319
320 66
            if (isset($register[4][7])) {
321 66
                $register[4][0] = true;
322
            }
323
        } else {
324 4
            throw new \Exception('invalid number of segments');
325
        }
326
327 66
        return $register;
328
    }
329
330
    /**
331
     * Parse one segment of a cron expression
332
     *
333
     * @param int $index
334
     * @param string $segment
335
     * @param array $register
336
     * @throws \Exception
337
     */
338 89
    private function parseSegment($index, array &$register, $segment)
339
    {
340 89
        $allowed = [false, false, false, self::$months, self::$weekdays];
341
342
        // month names, weekdays
343 89
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
344
            // cannot be used together with lists or ranges
345 5
            $register[$index][$allowed[$index][strtolower($segment)]] = true;
346
        } else {
347
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
348 89
            foreach (explode(',', $segment) as $element) {
349 89
                $this->parseElement($index, $register, $element);
350
            }
351
        }
352 77
    }
353
354
    /**
355
     * @param int $index
356
     * @param array $register
357
     * @param string $element
358
     * @throws \Exception
359
     */
360 89
    private function parseElement(int $index, array &$register, string $element)
361
    {
362 89
        $stepping = 1;
363
364 89
        if (false !== strpos($element, '/')) {
365 42
            $this->parseStepping($index, $element, $stepping);
366
        }
367
368 86
        if (is_numeric($element)) {
369 35
            $this->validateValue($index, $element);
370
371 29
            if ($stepping !== 1) {
372 1
                throw new \Exception('invalid combination of value and stepping notation');
373
            }
374
375 28
            $register[$index][intval($element)] = true;
376
        } else {
377 82
            $this->parseRange($index, $register, $element, $stepping);
378
        }
379 78
    }
380
381
    /**
382
     * Parse range of values, e.g. "5-10"
383
     *
384
     * @param int $index
385
     * @param array $register
386
     * @param string $range
387
     * @param int $stepping
388
     * @throws \Exception
389
     */
390 82
    private function parseRange(int $index, array &$register, string $range, int $stepping)
391
    {
392 82
        if ($range === '*') {
393 75
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
394 51
        } elseif (strpos($range, '-') !== false) {
395 44
            $range = $this->validateRange($index, explode('-', $range));
396
        } else {
397 8
            throw new \Exception('failed to parse list segment');
398
        }
399
400 77
        $this->fillRegister($index, $register, $range, $stepping);
401 77
    }
402
403
    /**
404
     * Parse stepping notation, e.g. "5-10/2" => 2
405
     *
406
     * @param int $index
407
     * @param string $element
408
     * @param int $stepping
409
     * @throws \Exception
410
     */
411 42
    private function parseStepping(int $index, string &$element, int &$stepping)
412
    {
413 42
        $segments = explode('/', $element);
414
415 42
        $this->validateStepping($index, $segments);
416
417 38
        $element = (string)$segments[0];
418 38
        $stepping = (int)$segments[1];
419 38
    }
420
421
    /**
422
     * Validate whether a given range of values exceeds allowed value boundaries
423
     *
424
     * @param int $index
425
     * @param array $range
426
     * @return array
427
     * @throws \Exception
428
     */
429 44
    private function validateRange(int $index, array $range): array
430
    {
431 44
        if (sizeof($range) !== 2) {
432 1
            throw new \Exception('invalid range notation');
433
        }
434
435 44
        foreach ($range as $value) {
436 44
            $this->validateValue($index, $value);
437
        }
438
439 41
        return $range;
440
    }
441
    /**
442
     * @param int $index
443
     * @param string $value
444
     * @throws \Exception
445
     */
446 68
    private function validateValue(int $index, string $value)
447
    {
448 68
        if (is_numeric($value)) {
449 68
            if (intval($value) < self::$boundaries[$index]['min'] ||
450 68
                intval($value) > self::$boundaries[$index]['max']) {
451 68
                throw new \Exception('value boundary exceeded');
452
            }
453
        } else {
454 1
            throw new \Exception('non-integer value');
455
        }
456 62
    }
457
458
    /**
459
     * @param int $index
460
     * @param array $segments
461
     * @throws \Exception
462
     */
463 42
    private function validateStepping(int $index, array $segments)
464
    {
465 42
        if (sizeof($segments) !== 2) {
466 1
            throw new \Exception('invalid stepping notation');
467
        }
468
469 41
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
470 3
            throw new \Exception('stepping out of allowed range');
471
        }
472 38
    }
473
474
    /**
475
     * @param int $index
476
     * @param array $register
477
     * @param array $range
478
     * @param int $stepping
479
     */
480 77
    private function fillRegister(int $index, array &$register, array $range, int $stepping)
481
    {
482 77
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
483 77
            if (($i - $range[0]) % $stepping === 0) {
484 77
                if ($range[0] < $range[1]) {
485 76
                    $this->fillRegisterBetweenBoundaries($index, $register, $range, $i);
486
                } else {
487 29
                    $this->fillRegisterAcrossBoundaries($index, $register, $range, $i);
488
                }
489
            }
490
        }
491 77
    }
492
493
    /**
494
     * @param int $index
495
     * @param array $register
496
     * @param array $range
497
     * @param int $value
498
     */
499 29
    private function fillRegisterAcrossBoundaries(int $index, array &$register, array $range, int $value)
500
    {
501 29
        if ($value >= $range[0] || $value <= $range[1]) {
502 29
            $register[$index][$value] = true;
503
        }
504 29
    }
505
506
    /**
507
     * @param int $index
508
     * @param array $register
509
     * @param array $range
510
     * @param int $value
511
     */
512 76
    private function fillRegisterBetweenBoundaries(int $index, array &$register, array $range, int $value)
513
    {
514 76
        if ($value >= $range[0] && $value <= $range[1]) {
515 76
            $register[$index][$value] = true;
516
        }
517 76
    }
518
}
519