Passed
Push — master ( db129d...768041 )
by René
02:41
created

Cron::adjust()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 35
Code Lines 26

Duplication

Lines 22
Ratio 62.86 %

Code Coverage

Tests 25
CRAP Score 6

Importance

Changes 0
Metric Value
dl 22
loc 35
ccs 25
cts 25
cp 1
rs 8.439
c 0
b 0
f 0
cc 6
eloc 26
nc 6
nop 2
crap 6
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 89
    public function __construct($expression = '* * * * *', \DateTimeZone $timeZone = null)
101
    {
102 89
        $this->setExpression($expression);
103 89
        $this->setTimeZone($timeZone);
104 89
    }
105
106
    /**
107
     * Set expression
108
     *
109
     * @param string $expression
110
     * @return self
111
     */
112 89
    public function setExpression($expression)
113
    {
114 89
        $this->expression = trim((string)$expression);
115 89
        $this->register = null;
116
117 89
        return $this;
118
    }
119
120
    /**
121
     * Set time zone
122
     *
123
     * @param \DateTimeZone $timeZone
124
     * @return self
125
     */
126 89
    public function setTimeZone(\DateTimeZone $timeZone = null)
127
    {
128 89
        $this->timeZone = $timeZone;
129 89
        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 7
    public function getNext($dtime = null)
139
    {
140 7
        $result = false;
141
142 7
        if ($this->isValid()) {
143 7
            if ($dtime instanceof \DateTime) {
144 1
                $timestamp = $dtime->getTimestamp();
145 7
            } elseif ((int)$dtime > 0) {
146 5
                $timestamp = $dtime;
147 5
            } else {
148 1
                $timestamp = time();
149
            }
150
151 7
            $dt = new \DateTime('now', $this->timeZone);
152 7
            $dt->setTimestamp(ceil($timestamp / 60) * 60);
153
154 7
            $pointer = sscanf($dt->format('i G j n Y'), '%d %d %d %d %d');
155
156
            do {
157 7
                $current = $this->adjust($dt, $pointer);
158 7
            } while ($this->forward($dt, $current));
159
160 7
            $result = $dt->getTimestamp();
161 7
        }
162
163 7
        return $result;
164
    }
165
166
    /**
167
     * @param \DateTime $dtime
168
     * @param array $pointer
169
     * @return array
170
     */
171 7
    private function adjust(\DateTime $dtime, array &$pointer)
172
    {
173 7
        $current = sscanf($dtime->format('i G j n Y w'), '%d %d %d %d %d %d');
174
175
        switch (true) {
176 7 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...
177 4
                $pointer[1] = $current[1];
178 4
                $dtime->setTime($current[1], 0);
179 4
                break;
180
181 7 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...
182 5
                $pointer[0] = $current[0];
183 5
                $dtime->setTime($current[1], $current[0]);
184 5
                break;
185
186 7 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...
187 1
                $pointer[4] = $current[4];
188 1
                $dtime->setDate($current[4], 1, 1);
189 1
                $dtime->setTime(0, 0);
190 1
                break;
191
192 7 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...
193 2
                $pointer[3] = $current[3];
194 2
                $dtime->setDate($current[4], $current[3], 1);
195 2
                $dtime->setTime(0, 0);
196 2
                break;
197
198 7 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...
199 2
                $pointer[2] = $current[2];
200 2
                $dtime->setTime(0, 0);
201 2
                break;
202
        }
203
204 7
        return $current;
205
    }
206
207
    /**
208
     * @param \DateTime $dtime
209
     * @param array $current
210
     * @return bool
211
     */
212 7
    private function forward(\DateTime $dtime, array $current)
213
    {
214 7
        $result = false;
215
216 7
        if (isset($this->register[3][$current[3]]) === false) {
217 1
            $dtime->modify('+1 month');
218 1
            $result = true;
219 7
        } elseif (false === (isset($this->register[2][$current[2]]) && isset($this->register[4][$current[5]]))) {
220 2
            $dtime->modify('+1 day');
221 2
            $result = true;
222 7
        } elseif (isset($this->register[1][$current[1]]) === false) {
223 3
            $dtime->modify('+1 hour');
224 3
            $result = true;
225 7
        } elseif (isset($this->register[0][$current[0]]) === false) {
226 4
            $dtime->modify('+1 minute');
227 4
            $result = true;
228 4
        }
229
230 7
        return $result;
231
    }
232
233
    /**
234
     * Parse and validate cron expression
235
     *
236
     * @return bool true if expression is valid, or false on error
237
     */
238 88
    public function isValid()
239
    {
240 88
        $result = true;
241
242 88
        if ($this->register === null) {
243
            try {
244 88
                $this->register = $this->parse();
245 88
            } catch (\Exception $e) {
246 27
                $result = false;
247
            }
248 88
        }
249
250 88
        return $result;
251
    }
252
253
    /**
254
     * Match current or given date/time against cron expression
255
     *
256
     * @param mixed $dtime \DateTime object, timestamp or null
257
     * @return bool
258
     */
259 82
    public function isMatching($dtime = null)
260
    {
261 82
        if (false === ($dtime instanceof \DateTime)) {
262 81
            $dt = new \DateTime();
263 81
            $dt->setTimestamp($dtime === null ? time() : $dtime);
264
265 81
            $dtime = $dt;
266 81
        }
267
268 82
        $dtime->setTimezone($this->timeZone);
269
270
        try {
271 82
            $result = $this->match(sscanf($dtime->format('i G j n w'), '%d %d %d %d %d'));
272 82
        } catch (\Exception $e) {
273 27
            $result = false;
274
        }
275
276 82
        return $result;
277
    }
278
279
    /**
280
     * @param array $segments
281
     * @return bool
282
     * @throws \Exception
283
     */
284 82
    private function match(array $segments)
285
    {
286 82
        $result = true;
287
288 82
        foreach ($this->parse() as $i => $item) {
289 55
            if (isset($item[(int)$segments[$i]]) === false) {
290 24
                $result = false;
291 24
                break;
292
            }
293 55
        }
294
295 55
        return $result;
296
    }
297
298
    /**
299
     * Parse whole cron expression
300
     *
301
     * @return array
302
     * @throws \Exception
303
     */
304 89
    private function parse()
305
    {
306 89
        $register = [];
307
308 89
        if (sizeof($segments = preg_split('/\s+/', $this->expression)) === 5) {
309 85
            foreach ($segments as $index => $segment) {
310 85
                $this->parseSegment($index, $register, $segment);
311 73
            }
312
313 62
            if (isset($register[4][7])) {
314 57
                $register[4][0] = true;
315 57
            }
316 62
        } else {
317 4
            throw new \Exception('invalid number of segments');
318
        }
319
320 62
        return $register;
321
    }
322
323
    /**
324
     * Parse one segment of a cron expression
325
     *
326
     * @param int $index
327
     * @param string $segment
328
     * @param array $register
329
     * @throws \Exception
330
     */
331 85
    private function parseSegment($index, array &$register, $segment)
332
    {
333 85
        $allowed = [false, false, false, self::$months, self::$weekdays];
334
335
        // month names, weekdays
336 85
        if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) {
337
            // cannot be used with lists or ranges, see crontab(5) man page
338 5
            $register[$index][$allowed[$index][strtolower($segment)]] = true;
339 5
        } else {
340
            // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ]
341 85
            foreach (explode(',', $segment) as $element) {
342 85
                $this->parseElement($index, $register, $element);
343 74
            }
344
        }
345 73
    }
346
347
    /**
348
     * @param int $index
349
     * @param array $register
350
     * @param string $element
351
     * @throws \Exception
352
     */
353 85
    private function parseElement($index, array &$register, $element)
354
    {
355 85
        $stepping = 1;
356
357 85
        if (false !== strpos($element, '/')) {
358 41
            $this->parseStepping($index, $element, $stepping);
359 37
        }
360
361 82
        if (is_numeric($element)) {
362 32
            $this->validateValue($index, $element);
363
364 26
            if ($stepping !== 1) {
365 1
                throw new \Exception('invalid combination of value and stepping notation');
366
            }
367
368 25
            $register[$index][intval($element)] = true;
369 25
        } else {
370 78
            $this->parseRange($index, $register, $element, $stepping);
371
        }
372 74
    }
373
374
    /**
375
     * Parse range of values, e.g. "5-10"
376
     *
377
     * @param int $index
378
     * @param array $register
379
     * @param string $range
380
     * @param int $stepping
381
     * @throws \Exception
382
     */
383 78
    private function parseRange($index, array &$register, $range, $stepping)
384
    {
385 78
        if ($range === '*') {
386 71
            $range = [self::$boundaries[$index]['min'], self::$boundaries[$index]['max']];
387 78
        } elseif (strpos($range, '-') !== false) {
388 44
            $range = $this->validateRange($index, explode('-', $range));
389 41
        } else {
390 8
            throw new \Exception('failed to parse list segment');
391
        }
392
393 73
        $this->fillRegister($index, $register, $range, $stepping);
394 73
    }
395
396
    /**
397
     * Parse stepping notation, e.g. "5-10/2" => 2
398
     *
399
     * @param int $index
400
     * @param string $element
401
     * @param int $stepping
402
     * @throws \Exception
403
     */
404 41
    private function parseStepping($index, &$element, &$stepping)
405
    {
406 41
        $segments = explode('/', $element);
407
408 41
        $this->validateStepping($index, $segments);
409
410 37
        $element = (string)$segments[0];
411 37
        $stepping = (int)$segments[1];
412 37
    }
413
414
    /**
415
     * Validate whether a given range of values exceeds allowed value boundaries
416
     *
417
     * @param int $index
418
     * @param array $range
419
     * @return array
420
     * @throws \Exception
421
     */
422 44
    private function validateRange($index, array $range)
423
    {
424 44
        if (sizeof($range) !== 2) {
425 1
            throw new \Exception('invalid range notation');
426
        }
427
428 44
        foreach ($range as $value) {
429 44
            $this->validateValue($index, $value);
430 44
        }
431
432 41
        return $range;
433
    }
434
    /**
435
     * @param int $index
436
     * @param int $value
437
     * @throws \Exception
438
     */
439 65
    private function validateValue($index, $value)
440
    {
441 65
        if (is_numeric($value)) {
442 65
            if (intval($value) < self::$boundaries[$index]['min'] ||
443 65
                intval($value) > self::$boundaries[$index]['max']) {
444 8
                throw new \Exception('value boundary exceeded');
445
            }
446 59
        } else {
447 1
            throw new \Exception('non-integer value');
448
        }
449 59
    }
450
451
    /**
452
     * @param int $index
453
     * @param array $segments
454
     * @throws \Exception
455
     */
456 41
    private function validateStepping($index, array $segments)
457
    {
458 41
        if (sizeof($segments) !== 2) {
459 1
            throw new \Exception('invalid stepping notation');
460
        }
461
462 40
        if ((int)$segments[1] <= 0 || (int)$segments[1] > self::$boundaries[$index]['max']) {
463 3
            throw new \Exception('stepping out of allowed range');
464
        }
465 37
    }
466
467
    /**
468
     * @param int $index
469
     * @param array $register
470
     * @param array $range
471
     * @param int $stepping
472
     */
473 73
    private function fillRegister($index, array &$register, array $range, $stepping)
474
    {
475 73
        for ($i = self::$boundaries[$index]['min']; $i <= self::$boundaries[$index]['max']; $i++) {
476 73
            if (($i - $range[0]) % $stepping === 0) {
477 73
                if ($range[0] < $range[1]) {
478 72
                    $this->fillRegisterBetweenBoundaries($index, $register, $range, $i);
479 72
                } else {
480 29
                    $this->fillRegisterAcrossBoundaries($index, $register, $range, $i);
481
                }
482 73
            }
483 73
        }
484 73
    }
485
486
    /**
487
     * @param int $index
488
     * @param array $register
489
     * @param array $range
490
     * @param int $value
491
     */
492 29
    private function fillRegisterAcrossBoundaries($index, array &$register, $range, $value)
493
    {
494 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...
495 29
            $register[$index][$value] = true;
496 29
        }
497 29
    }
498
499
    /**
500
     * @param int $index
501
     * @param array $register
502
     * @param array $range
503
     * @param int $value
504
     */
505 72
    private function fillRegisterBetweenBoundaries($index, array &$register, $range, $value)
506
    {
507 72 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...
508 72
            $register[$index][$value] = true;
509 72
        }
510 72
    }
511
}
512