Passed
Push — master ( 433772...4e5d44 )
by René
02:23
created

Cron::isMatching()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 21
ccs 13
cts 13
cp 1
rs 8.7624
c 0
b 0
f 0
cc 5
eloc 12
nc 8
nop 1
crap 5
1
<?php
2
3
/**
4
 * Cron expression parser and validator
5
 *
6
 * @author René Pollesch
7
 */
8
class Cron
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
9
{
10
    /**
11
     * Weekday look-up table
12
     *
13
     * @var array
14
     */
15
    protected static $weekdays = [
16
        'sun' => 0,
17
        'mon' => 1,
18
        'tue' => 2,
19
        'wed' => 3,
20
        'thu' => 4,
21
        'fri' => 5,
22
        'sat' => 6
23
    ];
24
25
    /**
26
     * Month name look-up table
27
     *
28
     * @var array
29
     */
30
    protected static $months = [
31
        'jan' => 1,
32
        'feb' => 2,
33
        'mar' => 3,
34
        'apr' => 4,
35
        'may' => 5,
36
        'jun' => 6,
37
        'jul' => 7,
38
        'aug' => 8,
39
        'sep' => 9,
40
        'oct' => 10,
41
        'nov' => 11,
42
        'dec' => 12
43
    ];
44
45
    /**
46
     * Value boundaries
47
     *
48
     * @var array
49
     */
50
    protected static $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 register
89
     *
90
     * @var array|null
91
     */
92
    protected $register;
93
94
    /**
95
     * Class constructor sets cron expression property
96
     *
97
     * @param string $expression cron expression
98
     * @param \DateTimeZone $timeZone
99
     */
100 90
    public function __construct($expression = '* * * * *', \DateTimeZone $timeZone = null)
101
    {
102 90
        $this->setExpression($expression);
103 90
        $this->setTimeZone($timeZone);
104 90
    }
105
106
    /**
107
     * Set expression
108
     *
109
     * @param string $expression
110
     * @return self
111
     */
112 90
    public function setExpression($expression)
113
    {
114 90
        $this->expression = trim((string)$expression);
115 90
        $this->register = null;
116
117 90
        return $this;
118
    }
119
120
    /**
121
     * Set time zone
122
     *
123
     * @param \DateTimeZone $timeZone
124
     * @return self
125
     */
126 90
    public function setTimeZone(\DateTimeZone $timeZone = null)
127
    {
128 90
        $this->timeZone = $timeZone;
129 90
        return $this;
130
    }
131
132
    /**
133
     * Calculate next matching timestamp
134
     *
135
     * @param mixed $dtime \DateTime object, timestamp or null
136
     * @return int|bool next matching timestamp, or false on error
137
     */
138 8
    public function getNext($dtime = null)
139
    {
140 8
        $result = false;
141
142 8
        if ($this->isValid()) {
143 8
            if ($dtime instanceof \DateTime) {
144 1
                $timestamp = $dtime->getTimestamp();
145 8
            } elseif ((int)$dtime > 0) {
146 6
                $timestamp = $dtime;
147 6
            } else {
148 1
                $timestamp = time();
149
            }
150
151 8
            $dt = new \DateTime('now', $this->timeZone);
152 8
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
153
154 8
            if ($this->isMatching($dt)) {
155 2
                $dt->modify('+1 minute');
156 2
            }
157
158 8
            $pointer = sscanf($dt->format('i G j n Y'), '%d %d %d %d %d');
159
160
            do {
161 8
                $current = $this->adjust($dt, $pointer);
162 8
            } while ($this->forward($dt, $current));
163
164 8
            $result = $dt->getTimestamp();
165 8
        }
166
167 8
        return $result;
168
    }
169
170
    /**
171
     * @param \DateTime $dtime
172
     * @param array $pointer
173
     * @return array
174
     */
175 8
    private function adjust(\DateTime $dtime, array &$pointer)
176
    {
177 8
        $current = sscanf($dtime->format('i G j n Y w'), '%d %d %d %d %d %d');
178
179
        switch (true) {
180 8 View Code Duplication
            case ($pointer[1] !== $current[1]):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
181 5
                $pointer[1] = $current[1];
182 5
                $dtime->setTime($current[1], 0);
183 5
                break;
184
185 8 View Code Duplication
            case ($pointer[0] !== $current[0]):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
186 7
                $pointer[0] = $current[0];
187 7
                $dtime->setTime($current[1], $current[0]);
188 7
                break;
189
190 8 View Code Duplication
            case ($pointer[4] !== $current[4]):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
191 1
                $pointer[4] = $current[4];
192 1
                $dtime->setDate($current[4], 1, 1);
193 1
                $dtime->setTime(0, 0);
194 1
                break;
195
196 8 View Code Duplication
            case ($pointer[3] !== $current[3]):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
197 2
                $pointer[3] = $current[3];
198 2
                $dtime->setDate($current[4], $current[3], 1);
199 2
                $dtime->setTime(0, 0);
200 2
                break;
201
202 8 View Code Duplication
            case ($pointer[2] !== $current[2]):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
203 2
                $pointer[2] = $current[2];
204 2
                $dtime->setTime(0, 0);
205 2
                break;
206
        }
207
208 8
        return $current;
209
    }
210
211
    /**
212
     * @param \DateTime $dtime
213
     * @param array $current
214
     * @return bool
215
     */
216 8
    private function forward(\DateTime $dtime, array $current)
217
    {
218 8
        $result = false;
219
220 8
        if (isset($this->register[3][$current[3]]) === false) {
221 1
            $dtime->modify('+1 month');
222 1
            $result = true;
223 8
        } elseif (false === (isset($this->register[2][$current[2]]) && isset($this->register[4][$current[5]]))) {
224 2
            $dtime->modify('+1 day');
225 2
            $result = true;
226 8
        } elseif (isset($this->register[1][$current[1]]) === false) {
227 4
            $dtime->modify('+1 hour');
228 4
            $result = true;
229 8
        } elseif (isset($this->register[0][$current[0]]) === false) {
230 6
            $dtime->modify('+1 minute');
231 6
            $result = true;
232 6
        }
233
234 8
        return $result;
235
    }
236
237
    /**
238
     * Parse and validate cron expression
239
     *
240
     * @return bool true if expression is valid, or false on error
241
     */
242 89
    public function isValid()
243
    {
244 89
        $result = true;
245
246 89
        if ($this->register === null) {
247
            try {
248 89
                $this->register = $this->parse();
249 89
            } catch (\Exception $e) {
250 27
                $result = false;
251
            }
252 89
        }
253
254 89
        return $result;
255
    }
256
257
    /**
258
     * Match current or given date/time against cron expression
259
     *
260
     * @param mixed $dtime \DateTime object, timestamp or null
261
     * @return bool
262
     */
263 90
    public function isMatching($dtime = null)
264
    {
265 90
        if (false === ($dtime instanceof \DateTime)) {
266 81
            $dt = new \DateTime();
267 81
            $dt->setTimestamp($dtime === null ? time() : $dtime);
268
269 81
            $dtime = $dt;
270 81
        }
271
272 90
        if ($this->timeZone !== null) {
273 88
            $dtime->setTimezone($this->timeZone);
274 88
        }
275
276
        try {
277 90
            $result = $this->match(sscanf($dtime->format('i G j n w'), '%d %d %d %d %d'));
278 90
        } catch (\Exception $e) {
279 27
            $result = false;
280
        }
281
282 90
        return $result;
283
    }
284
285
    /**
286
     * @param array $segments
287
     * @return bool
288
     * @throws \Exception
289
     */
290 90
    private function match(array $segments)
291
    {
292 90
        $result = true;
293
294 90
        foreach ($this->parse() as $i => $item) {
295 63
            if (isset($item[(int)$segments[$i]]) === false) {
296 31
                $result = false;
297 31
                break;
298
            }
299 63
        }
300
301 63
        return $result;
302
    }
303
304
    /**
305
     * Parse whole cron expression
306
     *
307
     * @return array
308
     * @throws \Exception
309
     */
310 90
    private function parse()
311
    {
312 90
        $register = [];
313
314 90
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
315 86
            foreach ($segments as $index => $segment) {
316 86
                $this->parseSegment($index, $register, $segment);
317 74
            }
318
319 63
            if (isset($register[4][7])) {
320 58
                $register[4][0] = true;
321 58
            }
322 63
        } else {
323 4
            throw new \Exception('invalid number of segments');
324
        }
325
326 63
        return $register;
327
    }
328
329
    /**
330
     * Parse one segment of a cron expression
331
     *
332
     * @param int $index
333
     * @param string $segment
334
     * @param array $register
335
     * @throws \Exception
336
     */
337 86
    private function parseSegment($index, array &$register, $segment)
338
    {
339 86
        $allowed = [false, false, false, self::$months, self::$weekdays];
340
341
        // month names, weekdays
342 86
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
343
            // cannot be used with lists or ranges, see crontab(5) man page
344 5
            $register[$index][$allowed[$index][strtolower($segment)]] = true;
345 5
        } else {
346
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
347 86
            foreach (explode(',', $segment) as $element) {
348 86
                $this->parseElement($index, $register, $element);
349 75
            }
350
        }
351 74
    }
352
353
    /**
354
     * @param int $index
355
     * @param array $register
356
     * @param string $element
357
     * @throws \Exception
358
     */
359 86
    private function parseElement($index, array &$register, $element)
360
    {
361 86
        $stepping = 1;
362
363 86
        if (false !== strpos($element, '/')) {
364 42
            $this->parseStepping($index, $element, $stepping);
365 38
        }
366
367 83
        if (is_numeric($element)) {
368 32
            $this->validateValue($index, $element);
369
370 26
            if ($stepping !== 1) {
371 1
                throw new \Exception('invalid combination of value and stepping notation');
372
            }
373
374 25
            $register[$index][intval($element)] = true;
375 25
        } else {
376 79
            $this->parseRange($index, $register, $element, $stepping);
377
        }
378 75
    }
379
380
    /**
381
     * Parse range of values, e.g. "5-10"
382
     *
383
     * @param int $index
384
     * @param array $register
385
     * @param string $range
386
     * @param int $stepping
387
     * @throws \Exception
388
     */
389 79
    private function parseRange($index, array &$register, $range, $stepping)
390
    {
391 79
        if ($range === '*') {
392 72
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
393 79
        } elseif (strpos($range, '-') !== false) {
394 44
            $range = $this->validateRange($index, explode('-', $range));
395 41
        } else {
396 8
            throw new \Exception('failed to parse list segment');
397
        }
398
399 74
        $this->fillRegister($index, $register, $range, $stepping);
400 74
    }
401
402
    /**
403
     * Parse stepping notation, e.g. "5-10/2" => 2
404
     *
405
     * @param int $index
406
     * @param string $element
407
     * @param int $stepping
408
     * @throws \Exception
409
     */
410 42
    private function parseStepping($index, &$element, &$stepping)
411
    {
412 42
        $segments = explode('/', $element);
413
414 42
        $this->validateStepping($index, $segments);
415
416 38
        $element = (string)$segments[0];
417 38
        $stepping = (int)$segments[1];
418 38
    }
419
420
    /**
421
     * Validate whether a given range of values exceeds allowed value boundaries
422
     *
423
     * @param int $index
424
     * @param array $range
425
     * @return array
426
     * @throws \Exception
427
     */
428 44
    private function validateRange($index, array $range)
429
    {
430 44
        if (sizeof($range) !== 2) {
431 1
            throw new \Exception('invalid range notation');
432
        }
433
434 44
        foreach ($range as $value) {
435 44
            $this->validateValue($index, $value);
436 44
        }
437
438 41
        return $range;
439
    }
440
    /**
441
     * @param int $index
442
     * @param int $value
443
     * @throws \Exception
444
     */
445 65
    private function validateValue($index, $value)
446
    {
447 65
        if (is_numeric($value)) {
448 65
            if (intval($value) < self::$boundaries[$index]['min'] ||
449 65
                intval($value) > self::$boundaries[$index]['max']) {
450 8
                throw new \Exception('value boundary exceeded');
451
            }
452 59
        } else {
453 1
            throw new \Exception('non-integer value');
454
        }
455 59
    }
456
457
    /**
458
     * @param int $index
459
     * @param array $segments
460
     * @throws \Exception
461
     */
462 42
    private function validateStepping($index, array $segments)
463
    {
464 42
        if (sizeof($segments) !== 2) {
465 1
            throw new \Exception('invalid stepping notation');
466
        }
467
468 41
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
469 3
            throw new \Exception('stepping out of allowed range');
470
        }
471 38
    }
472
473
    /**
474
     * @param int $index
475
     * @param array $register
476
     * @param array $range
477
     * @param int $stepping
478
     */
479 74
    private function fillRegister($index, array &$register, array $range, $stepping)
480
    {
481 74
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
482 74
            if (($i - $range[0]) % $stepping === 0) {
483 74
                if ($range[0] < $range[1]) {
484 73
                    $this->fillRegisterBetweenBoundaries($index, $register, $range, $i);
485 73
                } else {
486 29
                    $this->fillRegisterAcrossBoundaries($index, $register, $range, $i);
487
                }
488 74
            }
489 74
        }
490 74
    }
491
492
    /**
493
     * @param int $index
494
     * @param array $register
495
     * @param array $range
496
     * @param int $value
497
     */
498 29
    private function fillRegisterAcrossBoundaries($index, array &$register, $range, $value)
499
    {
500 29 View Code Duplication
        if ($value >= $range[0] || $value <= $range[1]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
501 29
            $register[$index][$value] = true;
502 29
        }
503 29
    }
504
505
    /**
506
     * @param int $index
507
     * @param array $register
508
     * @param array $range
509
     * @param int $value
510
     */
511 73
    private function fillRegisterBetweenBoundaries($index, array &$register, $range, $value)
512
    {
513 73 View Code Duplication
        if ($value >= $range[0] && $value <= $range[1]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
514 73
            $register[$index][$value] = true;
515 73
        }
516 73
    }
517
}
518