Passed
Push — master ( 5d0264...94a561 )
by René
01:16
created

CronExpression::fillRange()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4

Importance

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