Passed
Push — master ( e3613e...192e71 )
by René
01:15
created

CronExpression::adjust()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 24
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 19
nc 6
nop 2
dl 0
loc 24
ccs 18
cts 18
cp 1
crap 6
rs 9.0111
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
        if ($pointer[1] !== $current[1]) {
183 11
            $pointer[1] = $current[1];
184 6
            $now->setTime($current[1], 0);
185 6
        } elseif ($pointer[0] !== $current[0]) {
186 6
            $pointer[0] = $current[0];
187
            $now->setTime($current[1], $current[0]);
188 11
        } elseif ($pointer[4] !== $current[4]) {
189 9
            $pointer[4] = $current[4];
190 9
            $now->setDate($current[4], 1, 1);
191 9
            $now->setTime(0, 0);
192
        } elseif ($pointer[3] !== $current[3]) {
193 11
            $pointer[3] = $current[3];
194 1
            $now->setDate($current[4], $current[3], 1);
195 1
            $now->setTime(0, 0);
196 1
        } elseif ($pointer[2] !== $current[2]) {
197 1
            $pointer[2] = $current[2];
198
            $now->setTime(0, 0);
199 11
        }
200 2
201 2
        return $current;
202 2
    }
203 2
204
    /**
205 11
     * @param \DateTime $now
206 2
     * @param array $current
207 2
     * @return bool
208 2
     */
209
    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...
210
    {
211 11
        $result = false;
212
213
        if (isset($this->register[3][$current[3]]) === false) {
214
            $now->modify('+1 month');
215
            $result = true;
216
        } elseif (false === (isset($this->register[2][$current[2]]) && isset($this->register[4][$current[5]]))) {
217
            $now->modify('+1 day');
218
            $result = true;
219 11
        } elseif (isset($this->register[1][$current[1]]) === false) {
220
            $now->modify('+1 hour');
221 11
            $result = true;
222
        } elseif (isset($this->register[0][$current[0]]) === false) {
223 11
            $now->modify('+1 minute');
224 1
            $result = true;
225 1
        }
226 11
227 2
        return $result;
228 2
    }
229 11
230 4
    /**
231 4
     * Parse and validate cron expression
232 11
     *
233 8
     * @return bool true if expression is valid, or false on error
234 8
     */
235
    public function isValid(): bool
236
    {
237 11
        $result = true;
238
239
        if ($this->register === null) {
240
            try {
241
                $this->register = $this->parse();
242
            } catch (\Exception $e) {
243
                $result = false;
244
            }
245 92
        }
246
247 92
        return $result;
248
    }
249 92
250
    /**
251 92
     * Match current or given date/time against cron expression
252 27
     *
253 27
     * @param mixed $now \DateTime object, timestamp or null
254
     * @return bool
255
     * @throws \Exception
256
     */
257 92
    public function isMatching($now = null): bool
258
    {
259
        if (false === ($now instanceof \DateTime)) {
260
            $now = (new \DateTime())->setTimestamp($now === null ? time() : $now);
261
        }
262
263
        if ($this->timeZone !== null) {
264
            $now->setTimezone($this->timeZone);
265
        }
266
267 93
        try {
268
            $result = $this->match(sscanf($now->format('i G j n w'), '%d %d %d %d %d'));
269 93
        } catch (\Exception $e) {
270 81
            $result = false;
271
        }
272
273 93
        return $result;
274 91
    }
275
276
    /**
277
     * @param array $segments
278 93
     * @return bool
279 27
     * @throws \Exception
280 27
     */
281
    private function match(array $segments): bool
282
    {
283 93
        $result = true;
284
285
        foreach ($this->parse() as $i => $item) {
286
            if (isset($item[(int)$segments[$i]]) === false) {
287
                $result = false;
288
                break;
289
            }
290
        }
291 93
292
        return $result;
293 93
    }
294
295 93
    /**
296 66
     * Parse whole cron expression
297 32
     *
298 66
     * @return array
299
     * @throws \Exception
300
     */
301
    private function parse(): array
302 66
    {
303
        $register = [];
304
305
        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

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